ruby_routes 2.0.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.
@@ -1,24 +1,34 @@
1
1
  require 'uri'
2
2
  require 'timeout'
3
3
  require 'set'
4
+ require 'rack'
4
5
  require_relative 'route/small_lru'
6
+ require_relative 'utility/path_utility'
7
+ require_relative 'utility/key_builder_utility'
5
8
 
6
9
  module RubyRoutes
7
10
  class Route
11
+ include RubyRoutes::Utility::PathUtility
12
+ include RubyRoutes::Utility::KeyBuilderUtility
13
+
8
14
  attr_reader :path, :methods, :controller, :action, :name, :constraints, :defaults
9
15
 
16
+ EMPTY_ARRAY = [].freeze
17
+ EMPTY_PAIR = [EMPTY_ARRAY, EMPTY_ARRAY].freeze
18
+ EMPTY_STRING = ''.freeze
19
+
10
20
  def initialize(path, options = {})
11
21
  @path = normalize_path(path)
12
22
  # Pre-normalize and freeze methods at creation time
13
- raw_methods = Array(options[:via] || :get)
14
- @methods = raw_methods.map { |m| normalize_method(m) }.freeze
15
- @methods_set = @methods.to_set.freeze
16
- @controller = extract_controller(options)
17
- @action = options[:action] || extract_action(options[:to])
18
- @name = options[:as]
19
- @constraints = options[: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] || {}
20
30
  # Pre-normalize defaults to string keys and freeze
21
- @defaults = (options[:defaults] || {}).transform_keys(&:to_s).freeze
31
+ @defaults = (options[:defaults] || {}).transform_keys(&:to_s).freeze
22
32
 
23
33
  # Pre-compile everything at initialization
24
34
  precompile_route_data
@@ -33,6 +43,7 @@ module RubyRoutes
33
43
 
34
44
  def extract_params(request_path, parsed_qp = nil)
35
45
  path_params = extract_path_params_fast(request_path)
46
+
36
47
  return EMPTY_HASH unless path_params
37
48
 
38
49
  # Use optimized param building
@@ -57,35 +68,17 @@ module RubyRoutes
57
68
 
58
69
  # Optimized path generation with better caching and fewer allocations
59
70
  def generate_path(params = {})
60
- return ROOT_PATH if @path == ROOT_PATH
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?
61
76
 
62
- # Fast path: empty params and no required params
63
- if params.empty? && @required_params.empty?
64
- return @static_path if @static_path
65
- end
66
-
67
- # Build merged params efficiently
68
77
  merged = build_merged_params(params)
69
-
70
- # Check required params (fast Set operation)
71
- missing_params = @required_params_set - merged.keys
72
- unless missing_params.empty?
73
- raise RubyRoutes::RouteNotFound, "Missing params: #{missing_params.to_a.join(', ')}"
74
- end
75
-
76
- # Check for nil values in required params
77
- nil_params = @required_params_set.select { |param| merged[param].nil? }
78
- unless nil_params.empty?
79
- raise RubyRoutes::RouteNotFound, "Missing or nil params: #{nil_params.to_a.join(', ')}"
80
- end
81
-
82
- # Cache lookup
83
- cache_key = build_cache_key_fast(merged)
78
+ cache_key = cache_key_for_params(@required_params, merged)
84
79
  if (cached = @gen_cache.get(cache_key))
85
80
  return cached
86
81
  end
87
-
88
- # Generate path using string buffer (avoid array allocations)
89
82
  path_str = generate_path_string(merged)
90
83
  @gen_cache.set(cache_key, path_str)
91
84
  path_str
@@ -103,7 +96,7 @@ module RubyRoutes
103
96
  ROOT_PATH = '/'.freeze
104
97
  UNRESERVED_RE = /\A[a-zA-Z0-9\-._~]+\z/.freeze
105
98
  QUERY_CACHE_SIZE = 128
106
-
99
+
107
100
  # Common HTTP methods - interned for performance
108
101
  HTTP_GET = 'GET'.freeze
109
102
  HTTP_POST = 'POST'.freeze
@@ -132,6 +125,7 @@ module RubyRoutes
132
125
  @is_resource = @path.match?(/\/:id(?:$|\.)/)
133
126
  @gen_cache = SmallLru.new(512) # larger cache
134
127
  @query_cache = SmallLru.new(QUERY_CACHE_SIZE)
128
+ initialize_validation_cache
135
129
 
136
130
  compile_segments
137
131
  compile_required_params
@@ -191,7 +185,7 @@ module RubyRoutes
191
185
  # Validate constraints efficiently
192
186
  validate_constraints_fast!(result) unless @constraints.empty?
193
187
 
194
- result.dup
188
+ result
195
189
  end
196
190
 
197
191
  def get_thread_local_hash
@@ -220,31 +214,20 @@ module RubyRoutes
220
214
  return EMPTY_HASH if @compiled_segments.empty? && request_path == ROOT_PATH
221
215
  return nil if @compiled_segments.empty?
222
216
 
223
- # Fast path normalization
224
- path_parts = split_path_fast(request_path)
225
-
226
- # Check if we have a wildcard/splat segment
217
+ path_parts = split_path(request_path)
218
+
219
+ # Check for wildcard/splat segment
227
220
  has_splat = @compiled_segments.any? { |seg| seg[:type] == :splat }
228
-
221
+
229
222
  if has_splat
230
- # For wildcard routes, path can have more parts than segments
231
223
  return nil if path_parts.size < @compiled_segments.size - 1
232
224
  else
233
- # For non-wildcard routes, size must match exactly
234
225
  return nil if @compiled_segments.size != path_parts.size
235
226
  end
236
227
 
237
228
  extract_params_from_parts(path_parts)
238
229
  end
239
230
 
240
- def split_path_fast(request_path)
241
- # Optimized path splitting
242
- path = request_path
243
- path = path[1..-1] if path.start_with?('/')
244
- path = path[0...-1] if path.end_with?('/') && path != ROOT_PATH
245
- path.empty? ? [] : path.split('/')
246
- end
247
-
248
231
  def extract_params_from_parts(path_parts)
249
232
  params = {}
250
233
 
@@ -265,22 +248,16 @@ module RubyRoutes
265
248
 
266
249
  # Optimized merged params building
267
250
  def build_merged_params(params)
268
- return @defaults if params.empty?
269
-
270
- merged = get_thread_local_merged_hash
271
-
272
- # Merge defaults first if they exist
273
- merged.update(@defaults) unless @defaults.empty?
274
-
275
- # Use merge! with transform_keys for better performance
276
- if params.respond_to?(:transform_keys)
277
- merged.merge!(params.transform_keys(&:to_s))
278
- else
279
- # Fallback for older Ruby versions
280
- 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
281
259
  end
282
-
283
- merged
260
+ h
284
261
  end
285
262
 
286
263
  def get_thread_local_merged_hash
@@ -289,41 +266,43 @@ module RubyRoutes
289
266
  hash
290
267
  end
291
268
 
292
- # Fast cache key building with minimal allocations
293
- def build_cache_key_fast(merged)
294
- return '' if @required_params.empty?
295
-
296
- # Use array join which is faster than string concatenation
297
- parts = @required_params.map do |name|
298
- value = merged[name]
299
- value.is_a?(Array) ? value.join('/') : value.to_s
300
- end
301
- parts.join('|')
302
- end
303
-
304
269
  # Optimized path generation
305
270
  def generate_path_string(merged)
306
271
  return ROOT_PATH if @compiled_segments.empty?
307
272
 
308
- # Pre-allocate array for parts to avoid string buffer operations
309
- parts = []
310
-
273
+ # Estimate final path length to avoid resizing
274
+ estimated_size = 1 # For leading slash
311
275
  @compiled_segments.each do |seg|
312
276
  case seg[:type]
313
277
  when :static
314
- parts << seg[:value]
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]
315
294
  when :param
316
295
  value = merged.fetch(seg[:name]).to_s
317
- parts << encode_segment_fast(value)
296
+ path << encode_segment_fast(value)
318
297
  when :splat
319
298
  value = merged.fetch(seg[:name], '')
320
- parts << format_splat_value(value)
299
+ path << format_splat_value(value)
321
300
  end
301
+
302
+ path << '/' unless i == last_idx
322
303
  end
323
304
 
324
- # Single join operation is faster than multiple string concatenations
325
- path = "/#{parts.join('/')}"
326
- path == '/' ? ROOT_PATH : path
305
+ path
327
306
  end
328
307
 
329
308
  def format_splat_value(value)
@@ -340,10 +319,13 @@ module RubyRoutes
340
319
  # Fast segment encoding with caching for common values
341
320
  def encode_segment_fast(str)
342
321
  return str if UNRESERVED_RE.match?(str)
343
-
322
+
344
323
  # Cache encoded segments to avoid repeated encoding
345
324
  @encoding_cache ||= {}
346
- @encoding_cache[str] ||= URI.encode_www_form_component(str)
325
+ @encoding_cache[str] ||= begin
326
+ # Use URI.encode_www_form_component but replace + with %20 for path segments
327
+ URI.encode_www_form_component(str).gsub('+', '%20')
328
+ end
347
329
  end
348
330
 
349
331
  # Optimized query params with caching
@@ -352,7 +334,7 @@ module RubyRoutes
352
334
  return EMPTY_HASH unless query_start
353
335
 
354
336
  query_string = path[(query_start + 1)..-1]
355
- return EMPTY_HASH if query_string.empty?
337
+ return EMPTY_HASH if query_string.empty? || query_string.match?(/^\?+$/)
356
338
 
357
339
  # Cache query param parsing
358
340
  if (cached = @query_cache.get(query_string))
@@ -364,13 +346,6 @@ module RubyRoutes
364
346
  result
365
347
  end
366
348
 
367
- def normalize_path(path)
368
- path_str = path.to_s
369
- path_str = "/#{path_str}" unless path_str.start_with?('/')
370
- path_str = path_str.chomp('/') unless path_str == ROOT_PATH
371
- path_str
372
- end
373
-
374
349
  def extract_controller(options)
375
350
  to = options[:to]
376
351
  return options[:controller] unless to
@@ -382,6 +357,84 @@ module RubyRoutes
382
357
  to.to_s.split('#', 2).last
383
358
  end
384
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
+
385
438
  # Optimized constraint validation
386
439
  def validate_constraints_fast!(params)
387
440
  @constraints.each do |param, constraint|
@@ -403,7 +456,7 @@ module RubyRoutes
403
456
  when Proc
404
457
  # DEPRECATED: Proc constraints are deprecated due to security risks
405
458
  warn_proc_constraint_deprecation(param)
406
-
459
+
407
460
  # For backward compatibility, still execute but with strict timeout
408
461
  begin
409
462
  Timeout.timeout(0.05) do # Reduced timeout for security
@@ -442,23 +495,23 @@ module RubyRoutes
442
495
 
443
496
  def warn_proc_constraint_deprecation(param)
444
497
  return if @proc_warnings_shown&.include?(param)
445
-
498
+
446
499
  @proc_warnings_shown ||= Set.new
447
500
  @proc_warnings_shown << param
448
-
501
+
449
502
  warn <<~WARNING
450
503
  [DEPRECATION] Proc constraints are deprecated due to security risks.
451
-
504
+
452
505
  Parameter: #{param}
453
506
  Route: #{@path}
454
-
507
+
455
508
  Secure alternatives:
456
509
  - Use regex: constraints: { #{param}: /\\A\\d+\\z/ }
457
510
  - Use built-in types: constraints: { #{param}: :int }
458
511
  - Use hash constraints: constraints: { #{param}: { min_length: 3, format: /\\A[a-z]+\\z/ } }
459
-
512
+
460
513
  Available built-in types: :int, :uuid, :email, :slug, :alpha, :alphanumeric
461
-
514
+
462
515
  This warning will become an error in a future version.
463
516
  WARNING
464
517
  end
@@ -468,23 +521,23 @@ module RubyRoutes
468
521
  if constraint[:min_length] && value.length < constraint[:min_length]
469
522
  raise RubyRoutes::ConstraintViolation, "Value too short (minimum #{constraint[:min_length]} characters)"
470
523
  end
471
-
524
+
472
525
  if constraint[:max_length] && value.length > constraint[:max_length]
473
526
  raise RubyRoutes::ConstraintViolation, "Value too long (maximum #{constraint[:max_length]} characters)"
474
527
  end
475
-
528
+
476
529
  if constraint[:format] && !value.match?(constraint[:format])
477
530
  raise RubyRoutes::ConstraintViolation, "Value does not match required format"
478
531
  end
479
-
532
+
480
533
  if constraint[:in] && !constraint[:in].include?(value)
481
534
  raise RubyRoutes::ConstraintViolation, "Value not in allowed list"
482
535
  end
483
-
536
+
484
537
  if constraint[:not_in] && constraint[:not_in].include?(value)
485
538
  raise RubyRoutes::ConstraintViolation, "Value in forbidden list"
486
539
  end
487
-
540
+
488
541
  if constraint[:range] && !constraint[:range].cover?(value.to_i)
489
542
  raise RubyRoutes::ConstraintViolation, "Value not in allowed range"
490
543
  end
@@ -495,9 +548,5 @@ module RubyRoutes
495
548
  raise InvalidRoute, "Action is required" if @action.nil?
496
549
  raise InvalidRoute, "Invalid HTTP method: #{@methods}" if @methods.empty?
497
550
  end
498
-
499
- # Additional constants
500
- EMPTY_ARRAY = [].freeze
501
- EMPTY_STRING = ''.freeze
502
551
  end
503
552
  end