ruby_routes 1.0.0 → 2.0.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/README.md +110 -7
- data/lib/ruby_routes/constant.rb +3 -3
- data/lib/ruby_routes/node.rb +29 -20
- data/lib/ruby_routes/radix_tree.rb +94 -45
- data/lib/ruby_routes/route.rb +405 -144
- data/lib/ruby_routes/route_set.rb +122 -40
- data/lib/ruby_routes/router.rb +11 -11
- data/lib/ruby_routes/url_helpers.rb +25 -6
- data/lib/ruby_routes/version.rb +1 -1
- data/lib/ruby_routes.rb +1 -0
- metadata +1 -1
data/lib/ruby_routes/route.rb
CHANGED
@@ -1,4 +1,6 @@
|
|
1
1
|
require 'uri'
|
2
|
+
require 'timeout'
|
3
|
+
require 'set'
|
2
4
|
require_relative 'route/small_lru'
|
3
5
|
|
4
6
|
module RubyRoutes
|
@@ -7,199 +9,253 @@ module RubyRoutes
|
|
7
9
|
|
8
10
|
def initialize(path, options = {})
|
9
11
|
@path = normalize_path(path)
|
10
|
-
|
12
|
+
# 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
|
11
16
|
@controller = extract_controller(options)
|
12
17
|
@action = options[:action] || extract_action(options[:to])
|
13
18
|
@name = options[:as]
|
14
19
|
@constraints = options[:constraints] || {}
|
15
|
-
#
|
16
|
-
@defaults = (options[:defaults] || {}).transform_keys(&:to_s)
|
20
|
+
# Pre-normalize defaults to string keys and freeze
|
21
|
+
@defaults = (options[:defaults] || {}).transform_keys(&:to_s).freeze
|
17
22
|
|
23
|
+
# Pre-compile everything at initialization
|
24
|
+
precompile_route_data
|
18
25
|
validate_route!
|
19
26
|
end
|
20
27
|
|
21
28
|
def match?(request_method, request_path)
|
22
|
-
|
23
|
-
|
29
|
+
# Fast method check: use frozen Set for O(1) lookup
|
30
|
+
return false unless @methods_set.include?(request_method.to_s.upcase)
|
31
|
+
!!extract_path_params_fast(request_path)
|
24
32
|
end
|
25
33
|
|
26
34
|
def extract_params(request_path, parsed_qp = nil)
|
27
|
-
path_params =
|
28
|
-
return
|
35
|
+
path_params = extract_path_params_fast(request_path)
|
36
|
+
return EMPTY_HASH unless path_params
|
29
37
|
|
30
|
-
#
|
31
|
-
|
32
|
-
tmp.clear
|
33
|
-
|
34
|
-
# start with path params (they take precedence)
|
35
|
-
path_params.each { |k, v| tmp[k] = v }
|
36
|
-
|
37
|
-
# use provided parsed_qp if available, otherwise parse lazily only if needed
|
38
|
-
qp = parsed_qp
|
39
|
-
if qp.nil? && request_path.include?('?')
|
40
|
-
qp = query_params(request_path)
|
41
|
-
end
|
42
|
-
qp.each { |k, v| tmp[k] = v } if qp && !qp.empty?
|
43
|
-
|
44
|
-
# only set defaults for keys not already present
|
45
|
-
defaults.each { |k, v| tmp[k] = v unless tmp.key?(k) } if defaults
|
46
|
-
|
47
|
-
validate_constraints!(tmp)
|
48
|
-
tmp.dup
|
38
|
+
# Use optimized param building
|
39
|
+
build_params_hash(path_params, request_path, parsed_qp)
|
49
40
|
end
|
50
41
|
|
51
42
|
def named?
|
52
|
-
|
43
|
+
!@name.nil?
|
53
44
|
end
|
54
45
|
|
55
46
|
def resource?
|
56
|
-
|
47
|
+
@is_resource
|
57
48
|
end
|
58
49
|
|
59
50
|
def collection?
|
60
|
-
|
51
|
+
!@is_resource
|
61
52
|
end
|
62
53
|
|
63
54
|
def parse_query_params(path)
|
64
|
-
|
55
|
+
query_params_fast(path)
|
65
56
|
end
|
66
57
|
|
67
|
-
#
|
68
|
-
# Avoids unbounded cache growth and skips URI-encoding for safe values.
|
58
|
+
# Optimized path generation with better caching and fewer allocations
|
69
59
|
def generate_path(params = {})
|
70
|
-
return
|
60
|
+
return ROOT_PATH if @path == ROOT_PATH
|
71
61
|
|
72
|
-
#
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
params.each { |k, v| tmp[k.to_s] = v } if params
|
77
|
-
merged = tmp
|
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
|
78
66
|
|
79
|
-
|
80
|
-
|
67
|
+
# Build merged params efficiently
|
68
|
+
merged = build_merged_params(params)
|
81
69
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
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(', ')}"
|
86
74
|
end
|
87
75
|
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
when :param
|
93
|
-
v = merged.fetch(seg[:name]).to_s
|
94
|
-
safe_encode_segment(v)
|
95
|
-
when :splat
|
96
|
-
v = merged.fetch(seg[:name], '')
|
97
|
-
arr = v.is_a?(Array) ? v : v.to_s.split('/')
|
98
|
-
arr.map { |p| safe_encode_segment(p.to_s) }.join('/')
|
99
|
-
end
|
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(', ')}"
|
100
80
|
end
|
101
81
|
|
102
|
-
|
103
|
-
|
82
|
+
# Cache lookup
|
83
|
+
cache_key = build_cache_key_fast(merged)
|
84
|
+
if (cached = @gen_cache.get(cache_key))
|
85
|
+
return cached
|
86
|
+
end
|
104
87
|
|
105
|
-
|
106
|
-
|
88
|
+
# Generate path using string buffer (avoid array allocations)
|
89
|
+
path_str = generate_path_string(merged)
|
90
|
+
@gen_cache.set(cache_key, path_str)
|
91
|
+
path_str
|
92
|
+
end
|
93
|
+
|
94
|
+
# Fast query params method (cached and optimized)
|
95
|
+
def query_params(request_path)
|
96
|
+
query_params_fast(request_path)
|
107
97
|
end
|
108
98
|
|
109
99
|
private
|
110
100
|
|
111
|
-
#
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
101
|
+
# Constants for performance
|
102
|
+
EMPTY_HASH = {}.freeze
|
103
|
+
ROOT_PATH = '/'.freeze
|
104
|
+
UNRESERVED_RE = /\A[a-zA-Z0-9\-._~]+\z/.freeze
|
105
|
+
QUERY_CACHE_SIZE = 128
|
106
|
+
|
107
|
+
# Common HTTP methods - interned for performance
|
108
|
+
HTTP_GET = 'GET'.freeze
|
109
|
+
HTTP_POST = 'POST'.freeze
|
110
|
+
HTTP_PUT = 'PUT'.freeze
|
111
|
+
HTTP_PATCH = 'PATCH'.freeze
|
112
|
+
HTTP_DELETE = 'DELETE'.freeze
|
113
|
+
HTTP_HEAD = 'HEAD'.freeze
|
114
|
+
HTTP_OPTIONS = 'OPTIONS'.freeze
|
115
|
+
|
116
|
+
# Fast method normalization using interned constants
|
117
|
+
def normalize_method(method)
|
118
|
+
case method
|
119
|
+
when :get then HTTP_GET
|
120
|
+
when :post then HTTP_POST
|
121
|
+
when :put then HTTP_PUT
|
122
|
+
when :patch then HTTP_PATCH
|
123
|
+
when :delete then HTTP_DELETE
|
124
|
+
when :head then HTTP_HEAD
|
125
|
+
when :options then HTTP_OPTIONS
|
126
|
+
else method.to_s.upcase.freeze
|
121
127
|
end
|
122
128
|
end
|
123
129
|
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
130
|
+
# Pre-compile all route data at initialization
|
131
|
+
def precompile_route_data
|
132
|
+
@is_resource = @path.match?(/\/:id(?:$|\.)/)
|
133
|
+
@gen_cache = SmallLru.new(512) # larger cache
|
134
|
+
@query_cache = SmallLru.new(QUERY_CACHE_SIZE)
|
135
|
+
|
136
|
+
compile_segments
|
137
|
+
compile_required_params
|
138
|
+
check_static_path
|
128
139
|
end
|
129
140
|
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
part = if val.nil?
|
139
|
-
''
|
140
|
-
elsif val.is_a?(Array)
|
141
|
-
val.map!(&:to_s) && val.join('/')
|
142
|
-
else
|
143
|
-
val.to_s
|
144
|
-
end
|
145
|
-
buf << '|' unless i.zero?
|
146
|
-
buf << part
|
147
|
-
end
|
148
|
-
buf
|
141
|
+
def compile_segments
|
142
|
+
@compiled_segments = if @path == ROOT_PATH
|
143
|
+
EMPTY_ARRAY
|
144
|
+
else
|
145
|
+
@path.split('/').reject(&:empty?).map do |seg|
|
146
|
+
RubyRoutes::Constant.segment_descriptor(seg)
|
147
|
+
end.freeze
|
148
|
+
end
|
149
149
|
end
|
150
150
|
|
151
|
-
def
|
152
|
-
|
151
|
+
def compile_required_params
|
152
|
+
param_names = @compiled_segments.filter_map { |s| s[:name] if s[:type] != :static }
|
153
|
+
@param_names = param_names.freeze
|
154
|
+
@required_params = param_names.reject { |n| @defaults.key?(n) }.freeze
|
155
|
+
@required_params_set = @required_params.to_set.freeze
|
153
156
|
end
|
154
157
|
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
URI.encode_www_form_component(str)
|
158
|
+
def check_static_path
|
159
|
+
# Pre-generate static paths (no params)
|
160
|
+
if @required_params.empty?
|
161
|
+
@static_path = generate_static_path
|
162
|
+
end
|
161
163
|
end
|
162
164
|
|
163
|
-
def
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
165
|
+
def generate_static_path
|
166
|
+
return ROOT_PATH if @compiled_segments.empty?
|
167
|
+
|
168
|
+
parts = @compiled_segments.map { |seg| seg[:value] }
|
169
|
+
"/#{parts.join('/')}"
|
168
170
|
end
|
169
171
|
|
170
|
-
|
171
|
-
|
172
|
-
|
172
|
+
# Optimized param building
|
173
|
+
def build_params_hash(path_params, request_path, parsed_qp)
|
174
|
+
# Use pre-allocated hash when possible
|
175
|
+
result = get_thread_local_hash
|
176
|
+
|
177
|
+
# Path params first (highest priority)
|
178
|
+
result.update(path_params)
|
179
|
+
|
180
|
+
# Query params (if needed)
|
181
|
+
if parsed_qp
|
182
|
+
result.merge!(parsed_qp)
|
183
|
+
elsif request_path.include?('?')
|
184
|
+
qp = query_params_fast(request_path)
|
185
|
+
result.merge!(qp) unless qp.empty?
|
186
|
+
end
|
187
|
+
|
188
|
+
# Defaults (lowest priority)
|
189
|
+
merge_defaults_fast(result) unless @defaults.empty?
|
190
|
+
|
191
|
+
# Validate constraints efficiently
|
192
|
+
validate_constraints_fast!(result) unless @constraints.empty?
|
193
|
+
|
194
|
+
result.dup
|
195
|
+
end
|
196
|
+
|
197
|
+
def get_thread_local_hash
|
198
|
+
# Use a pool of hashes to reduce allocations
|
199
|
+
pool = Thread.current[:ruby_routes_hash_pool] ||= []
|
200
|
+
if pool.empty?
|
201
|
+
{}
|
173
202
|
else
|
174
|
-
|
203
|
+
hash = pool.pop
|
204
|
+
hash.clear
|
205
|
+
hash
|
175
206
|
end
|
176
207
|
end
|
177
208
|
|
178
|
-
def
|
179
|
-
|
180
|
-
|
209
|
+
def return_hash_to_pool(hash)
|
210
|
+
pool = Thread.current[:ruby_routes_hash_pool] ||= []
|
211
|
+
pool.push(hash) if pool.size < 5 # Keep pool small to avoid memory bloat
|
181
212
|
end
|
182
213
|
|
183
|
-
def
|
184
|
-
|
185
|
-
|
214
|
+
def merge_defaults_fast(result)
|
215
|
+
@defaults.each { |k, v| result[k] = v unless result.key?(k) }
|
216
|
+
end
|
186
217
|
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
218
|
+
# Fast path parameter extraction
|
219
|
+
def extract_path_params_fast(request_path)
|
220
|
+
return EMPTY_HASH if @compiled_segments.empty? && request_path == ROOT_PATH
|
221
|
+
return nil if @compiled_segments.empty?
|
222
|
+
|
223
|
+
# Fast path normalization
|
224
|
+
path_parts = split_path_fast(request_path)
|
225
|
+
|
226
|
+
# Check if we have a wildcard/splat segment
|
227
|
+
has_splat = @compiled_segments.any? { |seg| seg[:type] == :splat }
|
228
|
+
|
229
|
+
if has_splat
|
230
|
+
# For wildcard routes, path can have more parts than segments
|
231
|
+
return nil if path_parts.size < @compiled_segments.size - 1
|
232
|
+
else
|
233
|
+
# For non-wildcard routes, size must match exactly
|
234
|
+
return nil if @compiled_segments.size != path_parts.size
|
235
|
+
end
|
236
|
+
|
237
|
+
extract_params_from_parts(path_parts)
|
238
|
+
end
|
191
239
|
|
192
|
-
|
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
|
193
247
|
|
248
|
+
def extract_params_from_parts(path_parts)
|
194
249
|
params = {}
|
195
|
-
|
250
|
+
|
251
|
+
@compiled_segments.each_with_index do |seg, idx|
|
196
252
|
case seg[:type]
|
197
253
|
when :static
|
198
|
-
return nil unless seg[:value] ==
|
254
|
+
return nil unless seg[:value] == path_parts[idx]
|
199
255
|
when :param
|
200
|
-
params[seg[:name]] =
|
256
|
+
params[seg[:name]] = path_parts[idx]
|
201
257
|
when :splat
|
202
|
-
params[seg[:name]] =
|
258
|
+
params[seg[:name]] = path_parts[idx..-1].join('/')
|
203
259
|
break
|
204
260
|
end
|
205
261
|
end
|
@@ -207,36 +263,241 @@ module RubyRoutes
|
|
207
263
|
params
|
208
264
|
end
|
209
265
|
|
210
|
-
|
211
|
-
|
212
|
-
return
|
213
|
-
|
214
|
-
|
266
|
+
# Optimized merged params building
|
267
|
+
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 }
|
281
|
+
end
|
282
|
+
|
283
|
+
merged
|
284
|
+
end
|
285
|
+
|
286
|
+
def get_thread_local_merged_hash
|
287
|
+
hash = Thread.current[:ruby_routes_merged] ||= {}
|
288
|
+
hash.clear
|
289
|
+
hash
|
215
290
|
end
|
216
291
|
|
217
|
-
|
218
|
-
|
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
|
+
# Optimized path generation
|
305
|
+
def generate_path_string(merged)
|
306
|
+
return ROOT_PATH if @compiled_segments.empty?
|
307
|
+
|
308
|
+
# Pre-allocate array for parts to avoid string buffer operations
|
309
|
+
parts = []
|
310
|
+
|
311
|
+
@compiled_segments.each do |seg|
|
312
|
+
case seg[:type]
|
313
|
+
when :static
|
314
|
+
parts << seg[:value]
|
315
|
+
when :param
|
316
|
+
value = merged.fetch(seg[:name]).to_s
|
317
|
+
parts << encode_segment_fast(value)
|
318
|
+
when :splat
|
319
|
+
value = merged.fetch(seg[:name], '')
|
320
|
+
parts << format_splat_value(value)
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
# Single join operation is faster than multiple string concatenations
|
325
|
+
path = "/#{parts.join('/')}"
|
326
|
+
path == '/' ? ROOT_PATH : path
|
327
|
+
end
|
328
|
+
|
329
|
+
def format_splat_value(value)
|
330
|
+
case value
|
331
|
+
when Array
|
332
|
+
value.map { |part| encode_segment_fast(part.to_s) }.join('/')
|
333
|
+
when String
|
334
|
+
value.split('/').map { |part| encode_segment_fast(part) }.join('/')
|
335
|
+
else
|
336
|
+
encode_segment_fast(value.to_s)
|
337
|
+
end
|
338
|
+
end
|
339
|
+
|
340
|
+
# Fast segment encoding with caching for common values
|
341
|
+
def encode_segment_fast(str)
|
342
|
+
return str if UNRESERVED_RE.match?(str)
|
343
|
+
|
344
|
+
# Cache encoded segments to avoid repeated encoding
|
345
|
+
@encoding_cache ||= {}
|
346
|
+
@encoding_cache[str] ||= URI.encode_www_form_component(str)
|
347
|
+
end
|
348
|
+
|
349
|
+
# Optimized query params with caching
|
350
|
+
def query_params_fast(path)
|
351
|
+
query_start = path.index('?')
|
352
|
+
return EMPTY_HASH unless query_start
|
353
|
+
|
354
|
+
query_string = path[(query_start + 1)..-1]
|
355
|
+
return EMPTY_HASH if query_string.empty?
|
356
|
+
|
357
|
+
# Cache query param parsing
|
358
|
+
if (cached = @query_cache.get(query_string))
|
359
|
+
return cached
|
360
|
+
end
|
361
|
+
|
362
|
+
result = Rack::Utils.parse_query(query_string)
|
363
|
+
@query_cache.set(query_string, result)
|
364
|
+
result
|
365
|
+
end
|
366
|
+
|
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
|
+
def extract_controller(options)
|
375
|
+
to = options[:to]
|
376
|
+
return options[:controller] unless to
|
377
|
+
to.to_s.split('#', 2).first
|
378
|
+
end
|
379
|
+
|
380
|
+
def extract_action(to)
|
381
|
+
return nil unless to
|
382
|
+
to.to_s.split('#', 2).last
|
383
|
+
end
|
384
|
+
|
385
|
+
# Optimized constraint validation
|
386
|
+
def validate_constraints_fast!(params)
|
387
|
+
@constraints.each do |param, constraint|
|
219
388
|
value = params[param.to_s]
|
220
|
-
|
389
|
+
# Only skip validation if the parameter is completely missing from params
|
390
|
+
# Empty strings and nil values should still be validated
|
391
|
+
next unless params.key?(param.to_s)
|
221
392
|
|
222
393
|
case constraint
|
223
394
|
when Regexp
|
224
|
-
|
395
|
+
# Protect against ReDoS attacks with timeout
|
396
|
+
begin
|
397
|
+
Timeout.timeout(0.1) do
|
398
|
+
raise RubyRoutes::ConstraintViolation unless constraint.match?(value.to_s)
|
399
|
+
end
|
400
|
+
rescue Timeout::Error
|
401
|
+
raise RubyRoutes::ConstraintViolation, "Regex constraint timed out (potential ReDoS attack)"
|
402
|
+
end
|
225
403
|
when Proc
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
404
|
+
# DEPRECATED: Proc constraints are deprecated due to security risks
|
405
|
+
warn_proc_constraint_deprecation(param)
|
406
|
+
|
407
|
+
# For backward compatibility, still execute but with strict timeout
|
408
|
+
begin
|
409
|
+
Timeout.timeout(0.05) do # Reduced timeout for security
|
410
|
+
raise RubyRoutes::ConstraintViolation unless constraint.call(value.to_s)
|
411
|
+
end
|
412
|
+
rescue Timeout::Error
|
413
|
+
raise RubyRoutes::ConstraintViolation, "Proc constraint timed out (consider using secure alternatives)"
|
414
|
+
rescue => e
|
415
|
+
raise RubyRoutes::ConstraintViolation, "Proc constraint failed: #{e.message}"
|
231
416
|
end
|
417
|
+
when :int
|
418
|
+
value_str = value.to_s
|
419
|
+
raise RubyRoutes::ConstraintViolation unless value_str.match?(/\A\d+\z/)
|
420
|
+
when :uuid
|
421
|
+
value_str = value.to_s
|
422
|
+
raise RubyRoutes::ConstraintViolation unless value_str.length == 36 &&
|
423
|
+
value_str.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
|
424
|
+
when :email
|
425
|
+
value_str = value.to_s
|
426
|
+
raise RubyRoutes::ConstraintViolation unless value_str.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
|
427
|
+
when :slug
|
428
|
+
value_str = value.to_s
|
429
|
+
raise RubyRoutes::ConstraintViolation unless value_str.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/)
|
430
|
+
when :alpha
|
431
|
+
value_str = value.to_s
|
432
|
+
raise RubyRoutes::ConstraintViolation unless value_str.match?(/\A[a-zA-Z]+\z/)
|
433
|
+
when :alphanumeric
|
434
|
+
value_str = value.to_s
|
435
|
+
raise RubyRoutes::ConstraintViolation unless value_str.match?(/\A[a-zA-Z0-9]+\z/)
|
436
|
+
when Hash
|
437
|
+
# Secure hash-based constraints for common patterns
|
438
|
+
validate_hash_constraint!(constraint, value_str = value.to_s)
|
232
439
|
end
|
233
440
|
end
|
234
441
|
end
|
235
442
|
|
443
|
+
def warn_proc_constraint_deprecation(param)
|
444
|
+
return if @proc_warnings_shown&.include?(param)
|
445
|
+
|
446
|
+
@proc_warnings_shown ||= Set.new
|
447
|
+
@proc_warnings_shown << param
|
448
|
+
|
449
|
+
warn <<~WARNING
|
450
|
+
[DEPRECATION] Proc constraints are deprecated due to security risks.
|
451
|
+
|
452
|
+
Parameter: #{param}
|
453
|
+
Route: #{@path}
|
454
|
+
|
455
|
+
Secure alternatives:
|
456
|
+
- Use regex: constraints: { #{param}: /\\A\\d+\\z/ }
|
457
|
+
- Use built-in types: constraints: { #{param}: :int }
|
458
|
+
- Use hash constraints: constraints: { #{param}: { min_length: 3, format: /\\A[a-z]+\\z/ } }
|
459
|
+
|
460
|
+
Available built-in types: :int, :uuid, :email, :slug, :alpha, :alphanumeric
|
461
|
+
|
462
|
+
This warning will become an error in a future version.
|
463
|
+
WARNING
|
464
|
+
end
|
465
|
+
|
466
|
+
def validate_hash_constraint!(constraint, value)
|
467
|
+
# Secure hash-based constraints
|
468
|
+
if constraint[:min_length] && value.length < constraint[:min_length]
|
469
|
+
raise RubyRoutes::ConstraintViolation, "Value too short (minimum #{constraint[:min_length]} characters)"
|
470
|
+
end
|
471
|
+
|
472
|
+
if constraint[:max_length] && value.length > constraint[:max_length]
|
473
|
+
raise RubyRoutes::ConstraintViolation, "Value too long (maximum #{constraint[:max_length]} characters)"
|
474
|
+
end
|
475
|
+
|
476
|
+
if constraint[:format] && !value.match?(constraint[:format])
|
477
|
+
raise RubyRoutes::ConstraintViolation, "Value does not match required format"
|
478
|
+
end
|
479
|
+
|
480
|
+
if constraint[:in] && !constraint[:in].include?(value)
|
481
|
+
raise RubyRoutes::ConstraintViolation, "Value not in allowed list"
|
482
|
+
end
|
483
|
+
|
484
|
+
if constraint[:not_in] && constraint[:not_in].include?(value)
|
485
|
+
raise RubyRoutes::ConstraintViolation, "Value in forbidden list"
|
486
|
+
end
|
487
|
+
|
488
|
+
if constraint[:range] && !constraint[:range].cover?(value.to_i)
|
489
|
+
raise RubyRoutes::ConstraintViolation, "Value not in allowed range"
|
490
|
+
end
|
491
|
+
end
|
492
|
+
|
236
493
|
def validate_route!
|
237
|
-
raise InvalidRoute, "Controller is required" if controller.nil?
|
238
|
-
raise InvalidRoute, "Action is required" if action.nil?
|
239
|
-
raise InvalidRoute, "Invalid HTTP method: #{methods}" if methods.empty?
|
494
|
+
raise InvalidRoute, "Controller is required" if @controller.nil?
|
495
|
+
raise InvalidRoute, "Action is required" if @action.nil?
|
496
|
+
raise InvalidRoute, "Invalid HTTP method: #{@methods}" if @methods.empty?
|
240
497
|
end
|
498
|
+
|
499
|
+
# Additional constants
|
500
|
+
EMPTY_ARRAY = [].freeze
|
501
|
+
EMPTY_STRING = ''.freeze
|
241
502
|
end
|
242
|
-
end
|
503
|
+
end
|