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.
- checksums.yaml +4 -4
- data/lib/ruby_routes/constant.rb +1 -0
- data/lib/ruby_routes/node.rb +40 -41
- data/lib/ruby_routes/radix_tree.rb +194 -100
- data/lib/ruby_routes/route/small_lru.rb +4 -0
- data/lib/ruby_routes/route.rb +160 -111
- data/lib/ruby_routes/route_set.rb +96 -106
- data/lib/ruby_routes/router.rb +14 -9
- data/lib/ruby_routes/string_extensions.rb +3 -1
- data/lib/ruby_routes/url_helpers.rb +3 -2
- data/lib/ruby_routes/utility/key_builder_utility.rb +102 -0
- data/lib/ruby_routes/utility/path_utility.rb +42 -0
- data/lib/ruby_routes/utility/route_utility.rb +21 -0
- data/lib/ruby_routes/version.rb +1 -1
- metadata +18 -1
data/lib/ruby_routes/route.rb
CHANGED
@@ -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
|
14
|
-
@methods
|
15
|
-
@methods_set
|
16
|
-
@controller
|
17
|
-
@action
|
18
|
-
@name
|
19
|
-
@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
|
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
|
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
|
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
|
-
|
224
|
-
|
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
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
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
|
-
#
|
309
|
-
|
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
|
-
|
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
|
-
|
296
|
+
path << encode_segment_fast(value)
|
318
297
|
when :splat
|
319
298
|
value = merged.fetch(seg[:name], '')
|
320
|
-
|
299
|
+
path << format_splat_value(value)
|
321
300
|
end
|
301
|
+
|
302
|
+
path << '/' unless i == last_idx
|
322
303
|
end
|
323
304
|
|
324
|
-
|
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] ||=
|
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
|