ruby_routes 2.3.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.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rack'
3
+ require 'rack/utils'
4
4
 
5
5
  module RubyRoutes
6
6
  class Route
@@ -43,12 +43,12 @@ module RubyRoutes
43
43
  query_part = path[(query_index + 1)..]
44
44
  return RubyRoutes::Constant::EMPTY_HASH if query_part.empty? || query_part.match?(/^\?+$/)
45
45
 
46
- if (cached_result = @query_cache.get(query_part))
46
+ if (cached_result = @cache_mutex.synchronize { @query_cache.get(query_part) })
47
47
  return cached_result
48
48
  end
49
49
 
50
50
  parsed_result = Rack::Utils.parse_query(query_part)
51
- @query_cache.set(query_part, parsed_result)
51
+ @cache_mutex.synchronize { @query_cache.set(query_part, parsed_result) }
52
52
  parsed_result
53
53
  end
54
54
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
4
+ require_relative '../utility/path_utility'
5
+
3
6
  module RubyRoutes
4
7
  class Route
5
8
  # SegmentCompiler: path analysis + extraction
@@ -8,9 +11,9 @@ module RubyRoutes
8
11
  # a route path. It includes utilities for compiling path segments, required
9
12
  # parameters, and static paths, as well as extracting parameters from a
10
13
  # request path.
11
- #
12
- # @module RubyRoutes::Route::SegmentCompiler
13
14
  module SegmentCompiler
15
+ include RubyRoutes::Utility::PathUtility
16
+
14
17
  private
15
18
 
16
19
  # Compile the segments from the path.
@@ -43,7 +46,7 @@ module RubyRoutes
43
46
  dynamic_param_names.freeze
44
47
  else
45
48
  dynamic_param_names.reject do |name|
46
- @defaults.key?(name) || @defaults.key?(name.to_sym)
49
+ @defaults.key?(name) || (@defaults.key?(name.to_sym) if name.is_a?(String))
47
50
  end.freeze
48
51
  end
49
52
  @required_params_set = @required_params.to_set.freeze
@@ -39,19 +39,31 @@ module RubyRoutes
39
39
  # Validate required parameters once.
40
40
  #
41
41
  # This method validates that all required parameters are present and not
42
- # nil. It ensures that validation is performed only once per request.
42
+ # nil. It uses per-params caching to avoid re-validation for the same
43
+ # frozen params.
43
44
  #
44
45
  # @param params [Hash] The parameters to validate.
45
46
  # @raise [RouteNotFound] If required parameters are missing or nil.
46
47
  # @return [void]
47
48
  def validate_required_once(params)
48
- return if @required_params.empty? || @required_validated_once
49
+ return if @required_params.empty?
49
50
 
50
- missing, nils = validate_required_params(params)
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
51
65
  raise RouteNotFound, "Missing params: #{missing.join(', ')}" unless missing.empty?
52
66
  raise RouteNotFound, "Missing or nil params: #{nils.join(', ')}" unless nils.empty?
53
-
54
- @required_validated_once = true
55
67
  end
56
68
 
57
69
  # Validate required parameters.
@@ -64,13 +76,20 @@ module RubyRoutes
64
76
  # - `nils` [Array<String>] The keys of parameters that are nil.
65
77
  def validate_required_params(params)
66
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
67
84
 
68
85
  missing = []
69
86
  nils = []
70
87
  @required_params.each do |required_key|
71
88
  process_required_key(required_key, params, missing, nils)
72
89
  end
73
- [missing, nils]
90
+ result = [missing, nils]
91
+ cache_validation_result(params, result)
92
+ result
74
93
  end
75
94
 
76
95
  # Per-key validation helper used by `validate_required_params`.
@@ -85,10 +104,13 @@ module RubyRoutes
85
104
  def process_required_key(required_key, params, missing, nils)
86
105
  if params.key?(required_key)
87
106
  nils << required_key if params[required_key].nil?
88
- elsif params.key?(symbol_key = required_key.to_sym)
89
- nils << required_key if params[symbol_key].nil?
90
107
  else
91
- missing << required_key
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
92
114
  end
93
115
  end
94
116
 
@@ -104,7 +126,7 @@ module RubyRoutes
104
126
  return unless params.frozen?
105
127
  return unless @validation_cache && @validation_cache.size < 64
106
128
 
107
- @validation_cache.set(params.hash, result)
129
+ @cache_mutex.synchronize { @validation_cache.set(params.hash, result) }
108
130
  end
109
131
 
110
132
  # Fetch cached validation result.
@@ -114,7 +136,8 @@ module RubyRoutes
114
136
  # @param params [Hash] The parameters used for validation.
115
137
  # @return [Object, nil] The cached validation result, or `nil` if not found.
116
138
  def get_cached_validation(params)
117
- @validation_cache&.get(params.hash)
139
+ return nil unless params && @validation_cache
140
+ @cache_mutex.synchronize { @validation_cache.get(params.hash) }
118
141
  end
119
142
 
120
143
  # Return hash to pool.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
4
+
3
5
  module RubyRoutes
4
6
  class Route
5
7
  # WarningHelpers: encapsulate deprecation / warning helpers.
@@ -18,11 +20,12 @@ module RubyRoutes
18
20
  # is being emitted.
19
21
  # @return [void]
20
22
  def warn_proc_constraint_deprecation(param)
21
- return if @proc_warnings_shown&.include?(param)
23
+ key = param.to_sym
24
+ return if @proc_warnings_shown&.include?(key)
22
25
 
23
26
  @proc_warnings_shown ||= Set.new
24
- @proc_warnings_shown << param
25
- warn_proc_warning(param)
27
+ @proc_warnings_shown << key
28
+ warn_proc_warning(key)
26
29
  end
27
30
 
28
31
  # Warn about `Proc` constraint deprecation.
@@ -3,6 +3,7 @@
3
3
  require 'uri'
4
4
  require 'timeout'
5
5
  require 'rack'
6
+ require 'set'
6
7
  require_relative 'constant'
7
8
  require_relative 'node'
8
9
  require_relative 'route/small_lru'
@@ -35,9 +36,10 @@ module RubyRoutes
35
36
  # - Minimal object allocation in hot paths
36
37
  #
37
38
  # Thread Safety:
38
- # - Instance is effectively read‑only after initialization aside from
39
- # internal caches which are not synchronized but safe for typical
40
- # single writer (boot) + many reader (request) usage.
39
+ # - Instance is effectively read‑only after initialization.
40
+ # - Internal caches (@query_cache, @gen_cache, @validation_cache) are protected
41
+ # by a mutex for safe concurrent access across multiple threads.
42
+ # - Designed for "build during boot, read per request" usage pattern.
41
43
  #
42
44
  # Public API Surface (stable):
43
45
  # - #match?
@@ -166,6 +168,7 @@ module RubyRoutes
166
168
  @is_resource = @path.match?(%r{/:id(?:$|\.)})
167
169
  @gen_cache = SmallLru.new(512)
168
170
  @query_cache = SmallLru.new(RubyRoutes::Constant::QUERY_CACHE_SIZE)
171
+ @cache_mutex = Mutex.new # Thread-safe access to caches
169
172
  initialize_validation_cache
170
173
  compile_segments
171
174
  compile_required_params
@@ -40,104 +40,6 @@ module RubyRoutes
40
40
  @recognition_cache_max = 2048
41
41
  @cache_hits = 0
42
42
  @cache_misses = 0
43
- @request_key_pool = {}
44
- @request_key_ring = Array.new(RubyRoutes::Constant::REQUEST_KEY_CAPACITY)
45
- @entry_count = 0
46
- @ring_index = 0
47
- end
48
-
49
- # Fetch (or build) a composite request cache key with ring-buffer eviction.
50
- #
51
- # Ensures consistent use of frozen method/path keys to avoid mixed key space bugs.
52
- #
53
- # @param http_method [String, Symbol] The HTTP method (e.g., `:get`, `:post`).
54
- # @param request_path [String] The request path.
55
- # @return [String] The composite request key.
56
- def fetch_request_key(http_method, request_path)
57
- method_key, path_key = normalize_keys(http_method, request_path)
58
- composite_key = build_composite_key(method_key, path_key)
59
-
60
- return composite_key if handle_cache_hit(method_key, path_key, composite_key)
61
-
62
- handle_cache_miss(method_key, path_key, composite_key)
63
- composite_key
64
- end
65
-
66
- # Normalize keys.
67
- #
68
- # Converts the HTTP method and request path into frozen strings for consistent
69
- # key usage.
70
- #
71
- # @param http_method [String, Symbol] The HTTP method.
72
- # @param request_path [String] The request path.
73
- # @return [Array<String>] An array containing the normalized method and path keys.
74
- def normalize_keys(http_method, request_path)
75
- method_key = http_method.is_a?(String) ? http_method.upcase.freeze : http_method.to_s.upcase.freeze
76
- path_key = request_path.is_a?(String) ? request_path.freeze : request_path.to_s.freeze
77
- [method_key, path_key]
78
- end
79
-
80
- # Build composite key.
81
- #
82
- # Combines the HTTP method and path into a single composite key.
83
- #
84
- # @param method_key [String] The normalized HTTP method key.
85
- # @param path_key [String] The normalized path key.
86
- # @return [String] The composite key.
87
- def build_composite_key(method_key, path_key)
88
- "#{method_key}:#{path_key}".freeze
89
- end
90
-
91
- # Handle cache hit.
92
- #
93
- # Checks if the composite key already exists in the request key pool.
94
- #
95
- # @param method_key [String] The normalized HTTP method key.
96
- # @param path_key [String] The normalized path key.
97
- # @param _composite_key [String] The composite key (unused).
98
- # @return [Boolean] `true` if the key exists, `false` otherwise.
99
- def handle_cache_hit(method_key, path_key, _composite_key)
100
- return true if @request_key_pool[method_key]&.key?(path_key)
101
-
102
- false
103
- end
104
-
105
- # Handle cache miss.
106
- #
107
- # Adds the composite key to the request key pool and manages the ring buffer
108
- # for eviction.
109
- #
110
- # @param method_key [String] The normalized HTTP method key.
111
- # @param path_key [String] The normalized path key.
112
- # @param composite_key [String] The composite key.
113
- # @return [void]
114
- def handle_cache_miss(method_key, path_key, composite_key)
115
- @request_key_pool[method_key][path_key] = composite_key if @request_key_pool[method_key]
116
- @request_key_pool[method_key] = { path_key => composite_key } unless @request_key_pool[method_key]
117
-
118
- if @entry_count < RubyRoutes::Constant::REQUEST_KEY_CAPACITY
119
- @request_key_ring[@entry_count] = [method_key, path_key]
120
- @entry_count += 1
121
- else
122
- evict_old_entry(method_key, path_key)
123
- end
124
- end
125
-
126
- # Evict old entry.
127
- #
128
- # Removes the oldest entry from the request key pool and updates the ring buffer.
129
- #
130
- # @param method_key [String] The normalized HTTP method key.
131
- # @param path_key [String] The normalized path key.
132
- # @return [void]
133
- def evict_old_entry(method_key, path_key)
134
- evict_method, evict_path = @request_key_ring[@ring_index]
135
- if (evict_bucket = @request_key_pool[evict_method]) && evict_bucket.delete(evict_path) && evict_bucket.empty?
136
- @request_key_pool.delete(evict_method)
137
- end
138
- @request_key_ring[@ring_index] = [method_key, path_key]
139
- @ring_index += 1
140
- @ring_index = 0 if @ring_index == RubyRoutes::Constant::REQUEST_KEY_CAPACITY
141
43
  end
142
44
 
143
45
  # Fetch cached recognition entry while updating hit counter.
@@ -18,9 +18,11 @@ module RubyRoutes
18
18
  # @param route [Route] The route to add.
19
19
  # @return [Route] The added route.
20
20
  def add_to_collection(route)
21
- @routes << route
22
- @radix_tree.add(route.path, route.methods, route)
23
21
  @named_routes[route.name] = route if route.named?
22
+ @radix_tree.add(route.path, route.methods, route)
23
+ return route if @routes.include?(route) # Prevent duplicate insertion
24
+
25
+ @routes << route
24
26
  route
25
27
  end
26
28
  alias add_route add_to_collection
@@ -33,8 +35,7 @@ module RubyRoutes
33
35
  # @param route [Route] The route to register.
34
36
  # @return [Route] The registered route.
35
37
  def register(route)
36
- (@routes ||= []) << route
37
- route
38
+ add_to_collection(route)
38
39
  end
39
40
 
40
41
  # Find any route (no params) for a method/path.
@@ -55,7 +56,7 @@ module RubyRoutes
55
56
  # This method retrieves a route by its name from the named routes collection.
56
57
  # If no route is found, it raises a `RouteNotFound` error.
57
58
  #
58
- # @param name [Symbol, String] The name of the route.
59
+ # @param name [Symbol] The name of the route.
59
60
  # @return [Route] The named route.
60
61
  # @raise [RouteNotFound] If no route with the given name is found.
61
62
  def find_named_route(name)
@@ -68,8 +69,8 @@ module RubyRoutes
68
69
  # Clear all routes and caches.
69
70
  #
70
71
  # This method clears the internal route collection, named routes, recognition
71
- # cache, and radix tree. It also resets cache hit/miss counters and the
72
- # request key pool.
72
+ # cache, and radix tree. It also resets cache hit/miss counters and clears
73
+ # the global request key cache.
73
74
  #
74
75
  # @return [void]
75
76
  def clear!
@@ -79,10 +80,7 @@ module RubyRoutes
79
80
  @cache_hits = 0
80
81
  @cache_misses = 0
81
82
  @radix_tree = RadixTree.new
82
- @request_key_pool.clear
83
- @request_key_ring.fill(nil)
84
- @entry_count = 0
85
- @ring_index = 0
83
+ RubyRoutes::Utility::KeyBuilderUtility.clear!
86
84
  end
87
85
 
88
86
  # Get the number of routes.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'radix_tree'
3
4
  require_relative 'utility/key_builder_utility'
4
5
  require_relative 'utility/method_utility'
5
6
  require_relative 'route_set/cache_helpers'
@@ -18,7 +19,11 @@ module RubyRoutes
18
19
  # a small in‑memory recognition cache.
19
20
  # - Delegate structural path matching to an internal RadixTree.
20
21
  #
21
- # Thread safety: not thread‑safe; build during boot, read per request.
22
+ # Thread Safety:
23
+ # - RouteSet instances are not fully thread-safe for modifications.
24
+ # - Build during boot/initialization, then use read-only per request.
25
+ # - Global caches (via KeyBuilderUtility) are thread-safe for concurrent reads.
26
+ # - Per-instance recognition cache is not protected (single-threaded usage assumed).
22
27
  #
23
28
  # @api public (primary integration surface)
24
29
  class RouteSet
@@ -85,6 +90,18 @@ module RubyRoutes
85
90
  route.generate_path(params)
86
91
  end
87
92
 
93
+ # Replay recorded calls on the router instance.
94
+ #
95
+ # This method replays all the recorded route definitions and other
96
+ # configuration calls on the given router instance.
97
+ #
98
+ # @param router [Router] The router instance to replay calls on.
99
+ # @return [void]
100
+ def replay_recorded_calls(router)
101
+ # Placeholder for actual implementation
102
+ # Iterate over recorded calls and apply them to the router
103
+ end
104
+
88
105
  private
89
106
 
90
107
  # Set up the radix tree for structural path matching.
@@ -117,7 +134,11 @@ module RubyRoutes
117
134
  return nil unless matched_route
118
135
 
119
136
  # Ensure we have a mutable hash for merging defaults / query params.
120
- extracted_params = extracted_params.dup if extracted_params&.frozen?
137
+ if extracted_params.nil?
138
+ extracted_params = {}
139
+ elsif extracted_params.frozen?
140
+ extracted_params = extracted_params.dup
141
+ end
121
142
 
122
143
  merge_query_params(matched_route, raw_path, extracted_params)
123
144
  merge_defaults(matched_route, extracted_params)
@@ -17,7 +17,6 @@ module RubyRoutes
17
17
  def build
18
18
  router = Router.new
19
19
  validate_calls(@recorded_calls)
20
- router.freeze
21
20
  router.finalize!
22
21
  router
23
22
  end
@@ -62,8 +62,9 @@ module RubyRoutes
62
62
  # `RubyRoutes::Constant::RECORDED_METHODS`. Each method records its
63
63
  # invocation (method name, arguments, and block) in `@recorded_calls`.
64
64
  #
65
- # @param *arguments [Array] The arguments passed to the DSL method.
66
- # @param definition_block [Proc, nil] The block passed to the DSL method.
65
+ # The dynamically defined methods accept arbitrary arguments and an optional block,
66
+ # which are recorded for later processing by the router.
67
+ #
67
68
  # @return [nil]
68
69
  RubyRoutes::Constant::RECORDED_METHODS.each do |method_name|
69
70
  define_method(method_name) do |*arguments, &definition_block|
@@ -108,7 +108,7 @@ module RubyRoutes
108
108
  # @raise [RuntimeError] If the router is frozen.
109
109
  # @return [void]
110
110
  def ensure_unfrozen!
111
- raise 'Router finalized (immutable)' if @frozen
111
+ raise 'Router finalized (immutable)' if @frozen || frozen?
112
112
  end
113
113
 
114
114
  # Define routes for a singular resource.
@@ -61,7 +61,21 @@ module RubyRoutes
61
61
  # @param scoped_path [String] The path to prepend the scope's path to.
62
62
  # @return [void]
63
63
  def apply_path_scope(scope, scoped_path)
64
- scoped_path.prepend(scope[:path]) if scope[:path]
64
+ path = scope[:path]&.to_s
65
+ return if path.nil? || path.empty?
66
+
67
+ if path.end_with?('/')
68
+ if scoped_path.start_with?('/')
69
+ scoped_path.prepend(path.chomp('/'))
70
+ else
71
+ scoped_path.prepend(path)
72
+ end
73
+ else
74
+ scoped_path.prepend(scoped_path.start_with?('/') ? path : "#{path}/")
75
+ end
76
+
77
+ # Normalize: Ensure the final path starts with '/'
78
+ scoped_path.prepend('/') unless scoped_path.start_with?('/')
65
79
  end
66
80
 
67
81
  # Apply the module from a scope to the given options.
@@ -73,13 +87,17 @@ module RubyRoutes
73
87
  # @param scoped_options [Hash] The options to update with the module.
74
88
  # @return [void]
75
89
  def apply_module_scope(scope, scoped_options)
76
- return unless scope[:module]
90
+ module_string = scope[:module]&.to_s
91
+ return if module_string.nil? || module_string.empty?
77
92
 
78
- if scoped_options[:to]
79
- controller, action = scoped_options[:to].to_s.split('#', 2)
80
- scoped_options[:to] = "#{scope[:module]}/#{controller}##{action}"
81
- elsif scoped_options[:controller]
82
- scoped_options[:controller].prepend("#{scope[:module]}/")
93
+ if (to_val = scoped_options[:to])
94
+ controller, action = to_val.to_s.split('#', 2)
95
+ return if controller.nil? || controller.empty?
96
+ scoped_options[:to] = action && !action.empty? ? "#{module_string}/#{controller}##{action}" : "#{module_string}/#{controller}"
97
+ elsif (controller = scoped_options[:controller])
98
+ controller_string = controller.to_s
99
+ return if controller_string.empty?
100
+ scoped_options[:controller] = "#{module_string}/#{controller_string}"
83
101
  end
84
102
  end
85
103
 
@@ -91,7 +109,7 @@ module RubyRoutes
91
109
  def apply_defaults_scope(scope, scoped_options)
92
110
  return unless scope[:defaults]
93
111
 
94
- scoped_options[:defaults] = (scoped_options[:defaults] || {}).merge(scope[:defaults])
112
+ scoped_options[:defaults] = scope[:defaults].merge(scoped_options[:defaults] || {})
95
113
  end
96
114
 
97
115
  # Apply the constraints from a scope to the given options.
@@ -102,7 +120,7 @@ module RubyRoutes
102
120
  def apply_constraints_scope(scope, scoped_options)
103
121
  return unless scope[:constraints]
104
122
 
105
- scoped_options[:constraints] = (scoped_options[:constraints] || {}).merge(scope[:constraints])
123
+ scoped_options[:constraints] = scope[:constraints].merge(scoped_options[:constraints] || {})
106
124
  end
107
125
  end
108
126
  end
@@ -3,7 +3,8 @@
3
3
  require_relative 'utility/inflector_utility'
4
4
  require_relative 'utility/route_utility'
5
5
  require_relative 'router/http_helpers'
6
-
6
+ require_relative 'constant'
7
+ require_relative 'route_set'
7
8
  module RubyRoutes
8
9
  # RubyRoutes::Router
9
10
  #
@@ -117,6 +118,7 @@ module RubyRoutes
117
118
  # @return [Router] self.
118
119
  def resources(resource_name, options = {}, &nested_block)
119
120
  define_resource_routes(resource_name, options, &nested_block)
121
+ self
120
122
  end
121
123
 
122
124
  # Define a singular resource.
@@ -39,7 +39,9 @@ module RubyRoutes
39
39
  def ensure_child(parent_node)
40
40
  parent_node.wildcard_child ||= Node.new
41
41
  wildcard_child_node = parent_node.wildcard_child
42
- wildcard_child_node.param_name = @param_name
42
+ if wildcard_child_node.param_name.nil?
43
+ wildcard_child_node.param_name = @param_name
44
+ end
43
45
  wildcard_child_node
44
46
  end
45
47
 
@@ -14,9 +14,7 @@ module RubyRoutes
14
14
  # Design goals:
15
15
  # - Zero garbage on hot cache hits.
16
16
  # - Bounded memory (REQUEST_KEY_CAPACITY ring).
17
- # - Thread safety not required (intended for single request thread use).
18
- #
19
- # @module RubyRoutes::Utility::KeyBuilderUtility
17
+ # - Thread-safe for concurrent access across multiple threads.
20
18
  module KeyBuilderUtility
21
19
  # @!visibility private
22
20
  # { "GET" => { "/users" => "GET:/users" } }
@@ -28,12 +26,21 @@ module RubyRoutes
28
26
  @ring_index = 0
29
27
  # @!visibility private
30
28
  @entry_count = 0
29
+ # @!visibility private
30
+ @mutex = Mutex.new
31
31
 
32
32
  class << self
33
- # Expose pool for diagnostics (read‑only).
33
+ # Clear all cached request keys.
34
34
  #
35
- # @return [Hash] A shallow copy of the request key pool for diagnostics.
36
- attr_reader :request_key_pool
35
+ # @return [void]
36
+ def clear!
37
+ @mutex.synchronize do
38
+ @request_key_pool.clear
39
+ @request_key_ring.fill(nil)
40
+ @entry_count = 0
41
+ @ring_index = 0
42
+ end
43
+ end
37
44
 
38
45
  # Fetch (or create) a frozen "METHOD:PATH" composite key.
39
46
  #
@@ -46,12 +53,14 @@ module RubyRoutes
46
53
  # @param request_path [String] The request path (e.g., "/users").
47
54
  # @return [String] A frozen canonical key.
48
55
  def fetch_request_key(http_method, request_path)
49
- method_key, path_key = prepare_keys(http_method, request_path)
56
+ @mutex.synchronize do
57
+ method_key, path_key = prepare_keys(http_method, request_path)
50
58
 
51
- bucket = @request_key_pool[method_key] ||= {}
52
- return bucket[path_key] if bucket[path_key]
59
+ bucket = @request_key_pool[method_key] ||= {}
60
+ return bucket[path_key] if bucket[path_key]
53
61
 
54
- handle_cache_miss(bucket, method_key, path_key)
62
+ handle_cache_miss(bucket, method_key, path_key)
63
+ end
55
64
  end
56
65
 
57
66
  private
@@ -116,7 +125,7 @@ module RubyRoutes
116
125
 
117
126
  # Build a generic delimited key from components (non‑hot path).
118
127
  #
119
- # Uses a thread‑local mutable buffer to avoid transient objects.
128
+ # Simple join; acceptable for non‑hot paths.
120
129
  #
121
130
  # @param components [Array<#to_s>] The components to join into a key.
122
131
  # @param delimiter [String] The separator (default is ':').
@@ -159,8 +168,16 @@ module RubyRoutes
159
168
  # @param buffer [String] The buffer to build the key into.
160
169
  # @return [void]
161
170
  def build_param_key_buffer(required_params, merged, buffer)
162
- key_components = required_params.map { |param| format_param_value(merged[param]) }
163
- buffer << key_components.join('|')
171
+ first = true
172
+ required_params.each do |param|
173
+ value = format_param_value(merged[param])
174
+ if first
175
+ buffer << value
176
+ first = false
177
+ else
178
+ buffer << '|' << value
179
+ end
180
+ end
164
181
  end
165
182
 
166
183
  # Format a parameter value for inclusion in the key.
@@ -78,9 +78,9 @@ module RubyRoutes
78
78
  # @param method_input [String] The HTTP method input.
79
79
  # @return [String] The normalized HTTP method.
80
80
  def normalize_string_method(method_input)
81
- key = method_input
82
- return key if already_upper_ascii?(key)
81
+ return method_input if already_upper_ascii?(method_input)
83
82
 
83
+ key = method_input.dup
84
84
  METHOD_CACHE[key] ||= ascii_upcase(key).freeze
85
85
  end
86
86
 
@@ -98,9 +98,9 @@ module RubyRoutes
98
98
  # @return [String] The normalized HTTP method.
99
99
  def normalize_other_method(method_input)
100
100
  coerced = method_input.to_s
101
- key = coerced
102
- return key if already_upper_ascii?(key)
101
+ return coerced if already_upper_ascii?(coerced)
103
102
 
103
+ key = coerced.dup
104
104
  METHOD_CACHE[key] ||= ascii_upcase(key).freeze
105
105
  end
106
106
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyRoutes
4
- VERSION = '2.3.0'
4
+ VERSION = '2.4.0'
5
5
  end
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.3.0
4
+ version: 2.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yosef Benny Widyokarsono