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,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require_relative '../utility/path_utility'
5
+
6
+ module RubyRoutes
7
+ class Route
8
+ # SegmentCompiler: path analysis + extraction
9
+ #
10
+ # This module provides methods for analyzing and extracting segments from
11
+ # a route path. It includes utilities for compiling path segments, required
12
+ # parameters, and static paths, as well as extracting parameters from a
13
+ # request path.
14
+ module SegmentCompiler
15
+ include RubyRoutes::Utility::PathUtility
16
+
17
+ private
18
+
19
+ # Compile the segments from the path.
20
+ #
21
+ # This method splits the path into segments, analyzes each segment, and
22
+ # compiles metadata for static and dynamic segments.
23
+ #
24
+ # @return [void]
25
+ def compile_segments
26
+ @compiled_segments =
27
+ if @path == RubyRoutes::Constant::ROOT_PATH
28
+ RubyRoutes::Constant::EMPTY_ARRAY
29
+ else
30
+ @path.split('/').reject(&:empty?)
31
+ .map { |segment| RubyRoutes::Constant.segment_descriptor(segment) }
32
+ .freeze
33
+ end
34
+ end
35
+
36
+ # Compile the required parameters.
37
+ #
38
+ # This method identifies dynamic parameters in the path and determines
39
+ # which parameters are required based on the defaults provided.
40
+ #
41
+ # @return [void]
42
+ def compile_required_params
43
+ dynamic_param_names = @compiled_segments.filter_map { |segment| segment[:name] if segment[:type] != :static }
44
+ @param_names = dynamic_param_names.freeze
45
+ @required_params = if @defaults.empty?
46
+ dynamic_param_names.freeze
47
+ else
48
+ dynamic_param_names.reject do |name|
49
+ @defaults.key?(name) || (@defaults.key?(name.to_sym) if name.is_a?(String))
50
+ end.freeze
51
+ end
52
+ @required_params_set = @required_params.to_set.freeze
53
+ end
54
+
55
+ # Check if the path is static.
56
+ #
57
+ # This method determines if the path contains only static segments. If so,
58
+ # it generates the static path.
59
+ #
60
+ # @return [void]
61
+ def check_static_path
62
+ return unless @compiled_segments.all? { |segment| segment[:type] == :static }
63
+
64
+ @static_path = generate_static_path
65
+ end
66
+
67
+ # Generate the static path.
68
+ #
69
+ # This method constructs the static path from the compiled segments.
70
+ #
71
+ # @return [String] The generated static path.
72
+ def generate_static_path
73
+ return RubyRoutes::Constant::ROOT_PATH if @compiled_segments.empty?
74
+
75
+ "/#{@compiled_segments.map { |segment| segment[:value] }.join('/')}"
76
+ end
77
+
78
+ # Extract path parameters fast.
79
+ #
80
+ # This method extracts parameters from a request path based on the compiled
81
+ # segments. It performs validation and handles dynamic, static, and splat
82
+ # segments.
83
+ #
84
+ # @param request_path [String] The request path.
85
+ # @return [Hash, nil] The extracted parameters, or `nil` if extraction fails.
86
+ def extract_path_params_fast(request_path)
87
+ return RubyRoutes::Constant::EMPTY_HASH if root_path_and_empty_segments?(request_path)
88
+
89
+ return nil if @compiled_segments.empty?
90
+
91
+ path_parts = split_path(request_path)
92
+ return nil unless valid_parts_count?(path_parts)
93
+
94
+ extract_params_from_parts(path_parts)
95
+ end
96
+
97
+ # Check if it's a root path with empty segments.
98
+ #
99
+ # This method checks if the request path is the root path and the compiled
100
+ # segments are empty.
101
+ #
102
+ # @param request_path [String] The request path.
103
+ # @return [Boolean] `true` if the path is the root path with empty segments, `false` otherwise.
104
+ def root_path_and_empty_segments?(request_path)
105
+ @compiled_segments.empty? && request_path == RubyRoutes::Constant::ROOT_PATH
106
+ end
107
+
108
+ # Validate the parts count.
109
+ #
110
+ # This method checks if the number of parts in the request path matches
111
+ # the expected number of segments, accounting for splat segments.
112
+ #
113
+ # @param path_parts [Array<String>] The path parts.
114
+ # @return [Boolean] `true` if the parts count is valid, `false` otherwise.
115
+ def valid_parts_count?(path_parts)
116
+ has_splat = @compiled_segments.any? { |segment| segment[:type] == :splat }
117
+ (!has_splat && path_parts.size == @compiled_segments.size) ||
118
+ (has_splat && path_parts.size >= (@compiled_segments.size - 1))
119
+ end
120
+
121
+ # Extract parameters from parts.
122
+ #
123
+ # This method processes each segment and extracts parameters from the
124
+ # corresponding parts of the request path.
125
+ #
126
+ # @param path_parts [Array<String>] The path parts.
127
+ # @return [Hash, nil] The extracted parameters, or `nil` if extraction fails.
128
+ def extract_params_from_parts(path_parts)
129
+ params_hash = {}
130
+ @compiled_segments.each_with_index do |segment, index|
131
+ result = process_segment(segment, index, path_parts, params_hash)
132
+ return nil if result == false
133
+ break if result == :break
134
+ end
135
+ params_hash
136
+ end
137
+
138
+ # Process a segment.
139
+ #
140
+ # This method processes a single segment, extracting parameters or
141
+ # validating static segments.
142
+ #
143
+ # @param segment [Hash] The segment metadata.
144
+ # @param index [Integer] The index of the segment.
145
+ # @param path_parts [Array<String>] The path parts.
146
+ # @param params_hash [Hash] The parameters hash.
147
+ # @return [Boolean, Symbol] `true` if processed successfully,
148
+ # `false` if validation fails, `:break` for splat segments.
149
+ def process_segment(segment, index, path_parts, params_hash)
150
+ case segment[:type]
151
+ when :static
152
+ segment[:value] == path_parts[index]
153
+ when :param
154
+ params_hash[segment[:name]] = path_parts[index]
155
+ true
156
+ when :splat
157
+ params_hash[segment[:name]] = path_parts[index..].join('/')
158
+ :break
159
+ end
160
+ end
161
+
162
+ # Expose for testing / external callers that need fast path extraction.
163
+ public :extract_path_params_fast
164
+ end
165
+ end
166
+ end
@@ -1,47 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # RubyRoutes::Route::SmallLru
5
+ #
6
+ # A tiny fixed‑capacity Least Recently Used (LRU) cache optimized for:
7
+ # - Very small memory footprint
8
+ # - Low per‑operation overhead
9
+ # - Predictable eviction (oldest key removed when capacity exceeded)
10
+ #
11
+ # Implementation notes:
12
+ # - Backed by a Ruby Hash (insertion order preserves LRU ordering when we reinsert on hit)
13
+ # - get promotes an entry by deleting + reinserting (handled by hit strategy)
14
+ # - External pluggable strategies supply side effects for hit / miss (counters, promotions)
15
+ # - Eviction uses Hash#shift (O(1) for Ruby >= 2.5)
16
+ #
17
+ # Thread safety: NOT thread‑safe. Wrap with a Mutex externally if sharing across threads.
18
+ #
19
+ # Public API surface kept intentionally small for hot path use in routing / path caching.
20
+ #
1
21
  module RubyRoutes
2
22
  class Route
3
- # small LRU used for path generation cache
23
+ # SmallLru
24
+ # @!visibility public
4
25
  class SmallLru
5
- attr_reader :hits, :misses, :evictions
26
+ # @return [Integer] maximum number of entries retained
27
+ # @return [Integer] number of cache hits
28
+ # @return [Integer] number of cache misses
29
+ # @return [Integer] number of evictions (when capacity exceeded)
30
+ attr_reader :max_size, :hits, :misses, :evictions
6
31
 
7
- # larger default to reduce eviction likelihood in benchmarks
32
+ # @param max_size [Integer] positive maximum size
33
+ # @raise [ArgumentError] if max_size < 1
8
34
  def initialize(max_size = 1024)
9
- @max_size = max_size
10
- @h = {}
11
- @hits = 0
12
- @misses = 0
13
- @evictions = 0
35
+ max = Integer(max_size)
36
+ raise ArgumentError, 'max_size must be >= 1' if max < 1
14
37
 
15
- @hit_strategy = RubyRoutes::Constant::LRU_HIT_STRATEGY
38
+ @max_size = max
39
+ @hash = {} # { key => value } (insertion order = LRU order)
40
+ @hits = 0
41
+ @misses = 0
42
+ @evictions = 0
43
+ # Strategy objects must respond_to?(:call). They receive (lru, key) or (lru, key, value).
44
+ @hit_strategy = RubyRoutes::Constant::LRU_HIT_STRATEGY
16
45
  @miss_strategy = RubyRoutes::Constant::LRU_MISS_STRATEGY
17
46
  end
18
47
 
48
+ # Fetch a cached value.
49
+ #
50
+ # On hit:
51
+ # - Strategy updates hit count and reorders key (strategy expected to call increment_hits + promote)
52
+ # On miss:
53
+ # - Strategy updates miss count (strategy expected to call increment_misses)
54
+ #
55
+ # @param key [Object]
56
+ # @return [Object, nil] cached value or nil
19
57
  def get(key)
20
- strategy = @h.key?(key) ? @hit_strategy : @miss_strategy
21
- strategy.call(self, key)
58
+ lookup_strategy = @hash.key?(key) ? @hit_strategy : @miss_strategy
59
+ lookup_strategy.call(self, key)
22
60
  end
23
61
 
24
- def set(key, val)
25
- @h.delete(key) if @h.key?(key)
26
- @h[key] = val
27
- if @h.size > @max_size
28
- @h.shift
62
+ # Insert or update an entry.
63
+ # Re-inserts key to become most recently used.
64
+ # Evicts least recently used (Hash#shift) if capacity exceeded.
65
+ #
66
+ # @param key [Object]
67
+ # @param value [Object]
68
+ # @return [Object] value
69
+ def set(key, value)
70
+ @hash.delete(key) if @hash.key?(key) # promote existing
71
+ @hash[key] = value
72
+ if @hash.size > @max_size
73
+ @hash.shift
29
74
  @evictions += 1
30
75
  end
31
- val
76
+ value
32
77
  end
33
78
 
79
+ # @return [Integer] current number of entries
34
80
  def size
35
- @h.size
81
+ @hash.size
36
82
  end
37
83
 
84
+ # @return [Boolean]
85
+ def empty?
86
+ @hash.empty?
87
+ end
88
+
89
+ # @return [Array<Object>] keys in LRU order (oldest first)
90
+ def keys
91
+ @hash.keys
92
+ end
93
+
94
+ # Debug / spec helper (avoid exposing internal Hash directly).
95
+ # @return [Hash] shallow copy of internal store
96
+ def inspect_hash
97
+ @hash.dup
98
+ end
99
+
100
+ # Increment hit counter (intended for strategy objects).
101
+ # @return [void]
38
102
  def increment_hits
39
103
  @hits += 1
40
104
  end
41
105
 
106
+ # Increment miss counter (intended for strategy objects).
107
+ # @return [void]
42
108
  def increment_misses
43
109
  @misses += 1
44
110
  end
111
+
112
+ # Internal helper used by hit strategy to promote key.
113
+ # @param key [Object]
114
+ # @return [void]
115
+ def promote(key)
116
+ val = @hash.delete(key)
117
+ @hash[key] = val if val
118
+ end
119
+ private :promote
45
120
  end
46
121
  end
47
122
  end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'check_helpers'
4
+
5
+ module RubyRoutes
6
+ class Route
7
+ # ValidationHelpers: extracted validation and defaults logic.
8
+ #
9
+ # This module provides methods for validating routes, required parameters,
10
+ # and hash-form constraints. It also includes caching mechanisms for
11
+ # validation results and utilities for managing hash pools.
12
+ module ValidationHelpers
13
+ include RubyRoutes::Route::CheckHelpers
14
+
15
+ # Initialize validation result cache.
16
+ #
17
+ # This method initializes an LRU (Least Recently Used) cache for storing
18
+ # validation results, with a maximum size of 64 entries.
19
+ #
20
+ # @return [void]
21
+ def initialize_validation_cache
22
+ @validation_cache = SmallLru.new(64)
23
+ end
24
+
25
+ # Validate fundamental route shape.
26
+ #
27
+ # This method ensures that the route has a valid controller, action, and
28
+ # HTTP method. If any of these are invalid, an `InvalidRoute` exception
29
+ # is raised.
30
+ #
31
+ # @raise [InvalidRoute] If the route is invalid.
32
+ # @return [void]
33
+ def validate_route!
34
+ raise InvalidRoute, 'Controller is required' if @controller.nil?
35
+ raise InvalidRoute, 'Action is required' if @action.nil?
36
+ raise InvalidRoute, "Invalid HTTP method: #{@methods}" if @methods.empty?
37
+ end
38
+
39
+ # Validate required parameters once.
40
+ #
41
+ # This method validates that all required parameters are present and not
42
+ # nil. It uses per-params caching to avoid re-validation for the same
43
+ # frozen params.
44
+ #
45
+ # @param params [Hash] The parameters to validate.
46
+ # @raise [RouteNotFound] If required parameters are missing or nil.
47
+ # @return [void]
48
+ def validate_required_once(params)
49
+ return if @required_params.empty?
50
+
51
+ # Check cache for existing validation result
52
+ cached_result = get_cached_validation(params)
53
+ if cached_result
54
+ missing, nils = cached_result
55
+ else
56
+ # Perform validation
57
+ missing, nils = validate_required_params(params)
58
+ # Cache the result only if params are frozen
59
+ if params.frozen?
60
+ cache_validation_result(params, [missing, nils])
61
+ end
62
+ end
63
+
64
+ # Raise if invalid
65
+ raise RouteNotFound, "Missing params: #{missing.join(', ')}" unless missing.empty?
66
+ raise RouteNotFound, "Missing or nil params: #{nils.join(', ')}" unless nils.empty?
67
+ end
68
+
69
+ # Validate required parameters.
70
+ #
71
+ # This method checks for missing or nil required parameters.
72
+ #
73
+ # @param params [Hash] The parameters to validate.
74
+ # @return [Array<Array>] An array containing two arrays:
75
+ # - `missing` [Array<String>] The keys of missing parameters.
76
+ # - `nils` [Array<String>] The keys of parameters that are nil.
77
+ def validate_required_params(params)
78
+ return RubyRoutes::Constant::EMPTY_PAIR if @required_params.empty?
79
+ params ||= {}
80
+
81
+ if (cached = get_cached_validation(params))
82
+ return cached
83
+ end
84
+
85
+ missing = []
86
+ nils = []
87
+ @required_params.each do |required_key|
88
+ process_required_key(required_key, params, missing, nils)
89
+ end
90
+ result = [missing, nils]
91
+ cache_validation_result(params, result)
92
+ result
93
+ end
94
+
95
+ # Per-key validation helper used by `validate_required_params`.
96
+ #
97
+ # This method checks if a specific required key is present and not nil.
98
+ #
99
+ # @param required_key [String] The required parameter key.
100
+ # @param params [Hash] The parameters to validate.
101
+ # @param missing [Array<String>] The array to store missing keys.
102
+ # @param nils [Array<String>] The array to store keys with nil values.
103
+ # @return [void]
104
+ def process_required_key(required_key, params, missing, nils)
105
+ if params.key?(required_key)
106
+ nils << required_key if params[required_key].nil?
107
+ else
108
+ symbol_key = required_key.to_sym
109
+ if params.key?(symbol_key)
110
+ nils << required_key if params[symbol_key].nil?
111
+ else
112
+ missing << required_key
113
+ end
114
+ end
115
+ end
116
+
117
+ # Cache validation result.
118
+ #
119
+ # This method stores the validation result in the cache if the parameters
120
+ # are frozen and the cache is not full.
121
+ #
122
+ # @param params [Hash] The parameters used for validation.
123
+ # @param result [Object] The validation result to cache.
124
+ # @return [void]
125
+ def cache_validation_result(params, result)
126
+ return unless params.frozen?
127
+ return unless @validation_cache && @validation_cache.size < 64
128
+
129
+ @cache_mutex.synchronize { @validation_cache.set(params.hash, result) }
130
+ end
131
+
132
+ # Fetch cached validation result.
133
+ #
134
+ # This method retrieves a cached validation result for the given parameters.
135
+ #
136
+ # @param params [Hash] The parameters used for validation.
137
+ # @return [Object, nil] The cached validation result, or `nil` if not found.
138
+ def get_cached_validation(params)
139
+ return nil unless params && @validation_cache
140
+ @cache_mutex.synchronize { @validation_cache.get(params.hash) }
141
+ end
142
+
143
+ # Return hash to pool.
144
+ #
145
+ # This method returns a hash to the thread-local hash pool for reuse.
146
+ #
147
+ # @param hash [Hash] The hash to return to the pool.
148
+ # @return [void]
149
+ def return_hash_to_pool(hash)
150
+ pool = Thread.current[:ruby_routes_hash_pool] ||= []
151
+ pool.push(hash) if pool.size < 5
152
+ end
153
+
154
+ # Validate hash-form constraint rules.
155
+ #
156
+ # This method validates a value against a set of hash-form constraints,
157
+ # such as minimum length, maximum length, format, inclusion, exclusion,
158
+ # and range.
159
+ #
160
+ # @param constraint [Hash] The constraint rules.
161
+ # @param value [String] The value to validate.
162
+ # @raise [RubyRoutes::ConstraintViolation] If the value violates any constraint.
163
+ # @return [void]
164
+ def validate_hash_constraint!(constraint, value)
165
+ check_min_length(constraint, value)
166
+ check_max_length(constraint, value)
167
+ check_format(constraint, value)
168
+ check_in_list(constraint, value)
169
+ check_not_in_list(constraint, value)
170
+ check_range(constraint, value)
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module RubyRoutes
6
+ class Route
7
+ # WarningHelpers: encapsulate deprecation / warning helpers.
8
+ #
9
+ # This module provides methods for emitting deprecation warnings for
10
+ # deprecated features, such as `Proc` constraints, and suggests secure
11
+ # alternatives.
12
+ module WarningHelpers
13
+ # Emit deprecation warning for `Proc` constraints once per parameter.
14
+ #
15
+ # This method ensures that a deprecation warning for a `Proc` constraint
16
+ # is only emitted once per parameter. It tracks parameters for which
17
+ # warnings have already been shown.
18
+ #
19
+ # @param param [String, Symbol] The parameter name for which the warning
20
+ # is being emitted.
21
+ # @return [void]
22
+ def warn_proc_constraint_deprecation(param)
23
+ key = param.to_sym
24
+ return if @proc_warnings_shown&.include?(key)
25
+
26
+ @proc_warnings_shown ||= Set.new
27
+ @proc_warnings_shown << key
28
+ warn_proc_warning(key)
29
+ end
30
+
31
+ # Warn about `Proc` constraint deprecation.
32
+ #
33
+ # This method emits a detailed deprecation warning for `Proc` constraints,
34
+ # explaining the security risks and suggesting secure alternatives.
35
+ #
36
+ # @param param [String, Symbol] The parameter name for which the warning
37
+ # is being emitted.
38
+ # @return [void]
39
+ def warn_proc_warning(param)
40
+ warn <<~WARNING
41
+ [DEPRECATION] Proc constraints are deprecated due to security risks.
42
+
43
+ Parameter: #{param}; Route: #{@path}
44
+
45
+ Secure alternatives:
46
+ - Use regex: constraints: { #{param}: /\\A\\d+\\z/ }
47
+ - Use built-in types: constraints: { #{param}: :int }
48
+ - Use hash constraints: constraints: { #{param}: { min_length: 3, format: /\\A[a-z]+\\z/ } }
49
+
50
+ Available built-in types: :int, :uuid, :email, :slug, :alpha, :alphanumeric
51
+
52
+ This warning will become an error in a future version.
53
+ WARNING
54
+ end
55
+ end
56
+ end
57
+ end