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.
@@ -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
- @methods = Array(options[:via] || :get).map(&:to_s).map(&:upcase)
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
- # pre-normalize defaults to string keys to avoid per-request transform_keys
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
- return false unless methods.include?(request_method.to_s.upcase)
23
- !!extract_path_params(request_path)
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 = extract_path_params(request_path)
28
- return {} unless path_params
35
+ path_params = extract_path_params_fast(request_path)
36
+ return EMPTY_HASH unless path_params
29
37
 
30
- # Reuse a thread-local hash to reduce allocations; return a dup to callers.
31
- tmp = Thread.current[:ruby_routes_params] ||= {}
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
- !name.nil?
43
+ !@name.nil?
53
44
  end
54
45
 
55
46
  def resource?
56
- path.match?(/\/:id$/) || path.match?(/\/:id\./)
47
+ @is_resource
57
48
  end
58
49
 
59
50
  def collection?
60
- !resource?
51
+ !@is_resource
61
52
  end
62
53
 
63
54
  def parse_query_params(path)
64
- query_params(path)
55
+ query_params_fast(path)
65
56
  end
66
57
 
67
- # Fast path generator: uses precompiled token list and a small LRU.
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 '/' if path == '/'
60
+ return ROOT_PATH if @path == ROOT_PATH
71
61
 
72
- # build merged for only relevant param names, reusing a thread-local hash
73
- tmp = Thread.current[:ruby_routes_merged] ||= {}
74
- tmp.clear
75
- defaults.each { |k, v| tmp[k] = v } if defaults
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
- missing = compiled_required_params - merged.keys
80
- raise RubyRoutes::RouteNotFound, "Missing params: #{missing.join(', ')}" unless missing.empty?
67
+ # Build merged params efficiently
68
+ merged = build_merged_params(params)
81
69
 
82
- @gen_cache ||= SmallLru.new(256)
83
- cache_key = cache_key_for(merged)
84
- if (cached = @gen_cache.get(cache_key))
85
- return cached
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
- parts = compiled_segments.map do |seg|
89
- case seg[:type]
90
- when :static
91
- seg[:value]
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
- out = '/' + parts.join('/')
103
- out = '/' if out == ''
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
- @gen_cache.set(cache_key, out)
106
- out
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
- # compile helpers (memoize)
112
- def compiled_segments
113
- @compiled_segments ||= begin
114
- if path == '/'
115
- []
116
- else
117
- path.split('/').reject(&:empty?).map do |seg|
118
- RubyRoutes::Constant.segment_descriptor(seg)
119
- end
120
- end
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
- def compiled_required_params
125
- @compiled_required_params ||= compiled_segments.select { |s| s[:type] != :static }
126
- .map { |s| s[:name] }.uniq
127
- .reject { |n| defaults.to_s.include?(n) }
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
- # Cache key: deterministic param-order key (fast, stable)
131
- def cache_key_for(merged)
132
- # build key in route token order (parameters & splat) to avoid sorting/inspect
133
- names = compiled_param_names
134
- # build with single string buffer to avoid temporary arrays
135
- buf = +""
136
- names.each_with_index do |n, i|
137
- val = merged[n]
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 compiled_param_names
152
- @compiled_param_names ||= compiled_segments.map { |s| s[:name] if s[:type] != :static }.compact
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
- # Only URI-encode a segment when it contains unsafe chars.
156
- UNRESERVED_RE = /\A[a-zA-Z0-9\-._~]+\z/
157
- def safe_encode_segment(str)
158
- # leave slash handling to splat logic (splats already split)
159
- return str if UNRESERVED_RE.match?(str)
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 normalize_path(path)
164
- p = path.to_s
165
- p = "/#{p}" unless p.start_with?('/')
166
- p = p.chomp('/') unless p == '/'
167
- p
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
- def extract_controller(options)
171
- if options[:to]
172
- options[:to].to_s.split('#').first
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
- options[:controller]
203
+ hash = pool.pop
204
+ hash.clear
205
+ hash
175
206
  end
176
207
  end
177
208
 
178
- def extract_action(to)
179
- return nil unless to
180
- to.to_s.split('#').last
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 extract_path_params(request_path)
184
- segs = compiled_segments # memoized compiled route tokens
185
- return nil if segs.empty? && request_path != '/'
214
+ def merge_defaults_fast(result)
215
+ @defaults.each { |k, v| result[k] = v unless result.key?(k) }
216
+ end
186
217
 
187
- req = request_path
188
- req = req[1..-1] if req.start_with?('/')
189
- req = req[0...-1] if req.end_with?('/') && req != '/'
190
- request_parts = req == '' ? [] : req.split('/')
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
- return nil if segs.size != request_parts.size
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
- segs.each_with_index do |seg, idx|
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] == request_parts[idx]
254
+ return nil unless seg[:value] == path_parts[idx]
199
255
  when :param
200
- params[seg[:name]] = request_parts[idx]
256
+ params[seg[:name]] = path_parts[idx]
201
257
  when :splat
202
- params[seg[:name]] = request_parts[idx..-1].join('/')
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
- def query_params(path)
211
- qidx = path.index('?')
212
- return {} unless qidx
213
- qs = path[(qidx + 1)..-1] || ''
214
- Rack::Utils.parse_query(qs)
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
- def validate_constraints!(params)
218
- constraints.each do |param, constraint|
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
- next unless value
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
- raise ConstraintViolation unless constraint.match?(value)
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
- raise ConstraintViolation unless constraint.call(value)
227
- when Symbol
228
- case constraint
229
- when :int then raise ConstraintViolation unless value.match?(/^\d+$/)
230
- when :uuid then raise ConstraintViolation unless value.match?(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
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