ruby_routes 1.1.0 → 2.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15debcef313430cfc799afcb9ba0b6f9bd8292226d023d7e854afc608d5ede64
4
- data.tar.gz: 1d7c971980a984738c6239cc1727376b74ab61ae824ee054a92f254451574bcf
3
+ metadata.gz: fb6b2a38cb8f31bb3e79fa66dbbb1a0620c6eec1f668680e152efe38e7926b42
4
+ data.tar.gz: db5d62093e15a422d85e6c7ec373327ca1f37fd24f7b851c12bdb2f70cf81622
5
5
  SHA512:
6
- metadata.gz: 8f272672f91127b65fab8ed4ae2eacfb95fe55310fc4bcd017b5656a0eb475e05255884f19416520ce8b36ec6ff51602561b6f9519befc1f7cf7448bca4ab3f9
7
- data.tar.gz: c3cc386c128531ef9bb268bb904c806e1632c3d88609fd3de5077d8143f0fa56a1089ea0915de3bf2e1116c0d0d812402877480d6edc702f8923d3ee3252ef9a
6
+ metadata.gz: 7e84488c6309cea2dab99be9a350139f2042f3d9601e4a0d1d20eb3d66c5530934f2c4f0c5b384dec7fa3095802f4d710ab2cc4f1a0158486cd5bba618995801
7
+ data.tar.gz: 0fda0c79e0a935ba9227faba495030c9ff2144ebcc289969eed0a0eb95596d0d69289d1a12b160ef37c18d4021fd5ae172c12b7db48b3beeddb32435e6a2d7ad
data/README.md CHANGED
@@ -8,7 +8,7 @@ A lightweight, flexible routing system for Ruby that provides a Rails-like DSL f
8
8
  - **HTTP Method Support**: GET, POST, PUT, PATCH, DELETE, and custom methods
9
9
  - **RESTful Resources**: Automatic generation of RESTful routes
10
10
  - **Nested Routes**: Support for nested resources and namespaces
11
- - **Route Constraints**: Add constraints to routes (regex, etc.)
11
+ - **Secure Route Constraints**: Powerful constraint system with built-in security ([see CONSTRAINTS.md](CONSTRAINTS.md))
12
12
  - **Named Routes**: Generate URLs from route names
13
13
  - **Path Generation**: Build URLs with parameters
14
14
  - **Scope Support**: Group routes with common options
@@ -116,14 +116,91 @@ end
116
116
  # etc.
117
117
  ```
118
118
 
119
- ### Scopes and Constraints
119
+ ### Route Constraints
120
+
121
+ Ruby Routes provides a powerful and secure constraint system to validate route parameters. **For security reasons, Proc constraints are deprecated** - use the secure alternatives below.
122
+
123
+ #### Built-in Constraint Types
120
124
 
121
125
  ```ruby
122
126
  router = RubyRoutes.draw do
123
- scope constraints: { id: /\d+/ } do
124
- get '/users/:id', to: 'users#show'
125
- end
127
+ # Integer validation
128
+ get '/users/:id', to: 'users#show', constraints: { id: :int }
129
+
130
+ # UUID validation
131
+ get '/resources/:uuid', to: 'resources#show', constraints: { uuid: :uuid }
132
+
133
+ # Email validation
134
+ get '/users/:email', to: 'users#show', constraints: { email: :email }
126
135
 
136
+ # URL-friendly slug validation
137
+ get '/posts/:slug', to: 'posts#show', constraints: { slug: :slug }
138
+
139
+ # Alphabetic characters only
140
+ get '/categories/:name', to: 'categories#show', constraints: { name: :alpha }
141
+
142
+ # Alphanumeric characters only
143
+ get '/codes/:code', to: 'codes#show', constraints: { code: :alphanumeric }
144
+ end
145
+ ```
146
+
147
+ #### Hash-based Constraints (Recommended)
148
+
149
+ ```ruby
150
+ router = RubyRoutes.draw do
151
+ # Length constraints
152
+ get '/users/:username', to: 'users#show',
153
+ constraints: {
154
+ username: {
155
+ min_length: 3,
156
+ max_length: 20,
157
+ format: /\A[a-zA-Z0-9_]+\z/
158
+ }
159
+ }
160
+
161
+ # Allowed values (whitelist)
162
+ get '/posts/:status', to: 'posts#show',
163
+ constraints: {
164
+ status: { in: %w[draft published archived] }
165
+ }
166
+
167
+ # Numeric ranges
168
+ get '/products/:price', to: 'products#show',
169
+ constraints: {
170
+ price: { range: 1..10000 }
171
+ }
172
+ end
173
+ ```
174
+
175
+ #### Regular Expression Constraints
176
+
177
+ ```ruby
178
+ router = RubyRoutes.draw do
179
+ # Custom regex pattern (with ReDoS protection)
180
+ get '/products/:sku', to: 'products#show',
181
+ constraints: { sku: /\A[A-Z]{2}\d{4}\z/ }
182
+ end
183
+ ```
184
+
185
+ #### ⚠️ Security Notice: Proc Constraints Deprecated
186
+
187
+ ```ruby
188
+ # ❌ DEPRECATED - Security risk!
189
+ get '/users/:id', to: 'users#show',
190
+ constraints: { id: ->(value) { value.to_i > 0 } }
191
+
192
+ # ✅ Use secure alternatives instead:
193
+ get '/users/:id', to: 'users#show',
194
+ constraints: { id: { range: 1..Float::INFINITY } }
195
+ ```
196
+
197
+ **📚 For complete constraint documentation, see [CONSTRAINTS.md](CONSTRAINTS.md)**
198
+ **🔄 For migration help, see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md)**
199
+
200
+ ### Scopes
201
+
202
+ ```ruby
203
+ router = RubyRoutes.draw do
127
204
  scope defaults: { format: 'html' } do
128
205
  get '/posts', to: 'posts#index'
129
206
  end
@@ -273,13 +350,38 @@ Creates a new router instance and yields to the block for route definition.
273
350
  - `find_route(method, path)` - Finds a specific route
274
351
  - `find_named_route(name)` - Finds a named route
275
352
 
276
- ## Examples
353
+ ## Documentation
354
+
355
+ ### Core Documentation
356
+ - **[CONSTRAINTS.md](CONSTRAINTS.md)** - Complete guide to route constraints and security best practices
357
+ - **[MIGRATION_GUIDE.md](MIGRATION_GUIDE.md)** - Step-by-step guide for migrating from deprecated Proc constraints
358
+
359
+ ### Examples
277
360
 
278
361
  See the `examples/` directory for more detailed examples:
279
362
 
280
363
  - `examples/basic_usage.rb` - Basic routing examples
281
364
  - `examples/rack_integration.rb` - Full Rack application example
282
365
 
366
+ ## Security
367
+
368
+ Ruby Routes prioritizes security and has implemented several protections:
369
+
370
+ ### 🔒 Security Features
371
+ - **XSS Protection**: All HTML output is properly escaped
372
+ - **ReDoS Protection**: Regular expression constraints have timeout protection
373
+ - **Secure Constraints**: Deprecated dangerous Proc constraints in favor of secure alternatives
374
+ - **Thread Safety**: All caching and shared resources are thread-safe
375
+ - **Input Validation**: Comprehensive parameter validation before reaching application code
376
+
377
+ ### ⚠️ Important Security Notice
378
+ **Proc constraints are deprecated due to security risks** and will be removed in a future version. They allow arbitrary code execution which can be exploited for:
379
+ - Code injection attacks
380
+ - Denial of service attacks
381
+ - System compromise
382
+
383
+ **Migration Required**: If you're using Proc constraints, please migrate to secure alternatives using our [Migration Guide](MIGRATION_GUIDE.md).
384
+
283
385
  ## Testing
284
386
 
285
387
  Run the test suite:
@@ -288,6 +390,8 @@ Run the test suite:
288
390
  bundle exec rspec
289
391
  ```
290
392
 
393
+ The test suite includes comprehensive security tests to ensure all protections are working correctly.
394
+
291
395
  ## Contributing
292
396
 
293
397
  1. Fork the repository
@@ -1,3 +1,4 @@
1
+ require_relative 'segments/base_segment'
1
2
  require_relative 'segments/dynamic_segment'
2
3
  require_relative 'segments/static_segment'
3
4
  require_relative 'segments/wildcard_segment'
@@ -42,9 +43,9 @@ module RubyRoutes
42
43
 
43
44
  # Descriptor factories for segment classification (O(1) dispatch by first byte).
44
45
  DESCRIPTOR_FACTORIES = {
45
- 42 => ->(s) { { type: :splat, name: (s[1..-1] || 'splat') } }, # '*'
46
- 58 => ->(s) { { type: :param, name: s[1..-1] } }, # ':'
47
- :default => ->(s) { { type: :static, value: s } }
46
+ 42 => ->(s) { { type: :splat, name: (s[1..-1] || 'splat').freeze } }, # '*'
47
+ 58 => ->(s) { { type: :param, name: s[1..-1].freeze } }, # ':'
48
+ :default => ->(s) { { type: :static, value: s.freeze } } # Intern static values
48
49
  }.freeze
49
50
 
50
51
  def self.segment_descriptor(raw)
@@ -36,8 +36,13 @@ module RubyRoutes
36
36
  end
37
37
 
38
38
  def find(path, method, params_out = nil)
39
+ # Handle nil path and method cases
40
+ path ||= ''
41
+ method = method.to_s.upcase if method
42
+ # Strip query string before matching
43
+ clean_path = path.split('?', 2).first || ''
39
44
  # Fast path: root route
40
- if path == '/' || path.empty?
45
+ if clean_path == '/' || clean_path.empty?
41
46
  handler = @root.get_handler(method)
42
47
  if @root.is_endpoint && handler
43
48
  return [handler, params_out || {}]
@@ -46,7 +51,7 @@ module RubyRoutes
46
51
  end
47
52
  end
48
53
 
49
- segments = split_path_cached(path)
54
+ segments = split_path_cached(clean_path)
50
55
  current = @root
51
56
  params = params_out || {}
52
57
  params.clear if params_out
@@ -1,4 +1,7 @@
1
1
  require 'uri'
2
+ require 'timeout'
3
+ require 'set'
4
+ require 'rack'
2
5
  require_relative 'route/small_lru'
3
6
 
4
7
  module RubyRoutes
@@ -31,6 +34,7 @@ module RubyRoutes
31
34
 
32
35
  def extract_params(request_path, parsed_qp = nil)
33
36
  path_params = extract_path_params_fast(request_path)
37
+
34
38
  return EMPTY_HASH unless path_params
35
39
 
36
40
  # Use optimized param building
@@ -71,6 +75,12 @@ module RubyRoutes
71
75
  raise RubyRoutes::RouteNotFound, "Missing params: #{missing_params.to_a.join(', ')}"
72
76
  end
73
77
 
78
+ # Check for nil values in required params
79
+ nil_params = @required_params_set.select { |param| merged[param].nil? }
80
+ unless nil_params.empty?
81
+ raise RubyRoutes::RouteNotFound, "Missing or nil params: #{nil_params.to_a.join(', ')}"
82
+ end
83
+
74
84
  # Cache lookup
75
85
  cache_key = build_cache_key_fast(merged)
76
86
  if (cached = @gen_cache.get(cache_key))
@@ -96,18 +106,27 @@ module RubyRoutes
96
106
  UNRESERVED_RE = /\A[a-zA-Z0-9\-._~]+\z/.freeze
97
107
  QUERY_CACHE_SIZE = 128
98
108
 
99
- # Fast method normalization
109
+ # Common HTTP methods - interned for performance
110
+ HTTP_GET = 'GET'.freeze
111
+ HTTP_POST = 'POST'.freeze
112
+ HTTP_PUT = 'PUT'.freeze
113
+ HTTP_PATCH = 'PATCH'.freeze
114
+ HTTP_DELETE = 'DELETE'.freeze
115
+ HTTP_HEAD = 'HEAD'.freeze
116
+ HTTP_OPTIONS = 'OPTIONS'.freeze
117
+
118
+ # Fast method normalization using interned constants
100
119
  def normalize_method(method)
101
120
  case method
102
- when :get then 'GET'
103
- when :post then 'POST'
104
- when :put then 'PUT'
105
- when :patch then 'PATCH'
106
- when :delete then 'DELETE'
107
- when :head then 'HEAD'
108
- when :options then 'OPTIONS'
109
- else method.to_s.upcase
110
- end.freeze
121
+ when :get then HTTP_GET
122
+ when :post then HTTP_POST
123
+ when :put then HTTP_PUT
124
+ when :patch then HTTP_PATCH
125
+ when :delete then HTTP_DELETE
126
+ when :head then HTTP_HEAD
127
+ when :options then HTTP_OPTIONS
128
+ else method.to_s.upcase.freeze
129
+ end
111
130
  end
112
131
 
113
132
  # Pre-compile all route data at initialization
@@ -178,9 +197,20 @@ module RubyRoutes
178
197
  end
179
198
 
180
199
  def get_thread_local_hash
181
- hash = Thread.current[:ruby_routes_params] ||= {}
182
- hash.clear
183
- hash
200
+ # Use a pool of hashes to reduce allocations
201
+ pool = Thread.current[:ruby_routes_hash_pool] ||= []
202
+ if pool.empty?
203
+ {}
204
+ else
205
+ hash = pool.pop
206
+ hash.clear
207
+ hash
208
+ end
209
+ end
210
+
211
+ def return_hash_to_pool(hash)
212
+ pool = Thread.current[:ruby_routes_hash_pool] ||= []
213
+ pool.push(hash) if pool.size < 5 # Keep pool small to avoid memory bloat
184
214
  end
185
215
 
186
216
  def merge_defaults_fast(result)
@@ -192,16 +222,23 @@ module RubyRoutes
192
222
  return EMPTY_HASH if @compiled_segments.empty? && request_path == ROOT_PATH
193
223
  return nil if @compiled_segments.empty?
194
224
 
195
- # Fast path normalization
196
225
  path_parts = split_path_fast(request_path)
197
- return nil if @compiled_segments.size != path_parts.size
226
+
227
+ # Check for wildcard/splat segment
228
+ has_splat = @compiled_segments.any? { |seg| seg[:type] == :splat }
229
+
230
+ if has_splat
231
+ return nil if path_parts.size < @compiled_segments.size - 1
232
+ else
233
+ return nil if @compiled_segments.size != path_parts.size
234
+ end
198
235
 
199
236
  extract_params_from_parts(path_parts)
200
237
  end
201
238
 
202
239
  def split_path_fast(request_path)
203
- # Optimized path splitting
204
- path = request_path
240
+ # Remove query string before splitting
241
+ path = request_path.split('?', 2).first
205
242
  path = path[1..-1] if path.start_with?('/')
206
243
  path = path[0...-1] if path.end_with?('/') && path != ROOT_PATH
207
244
  path.empty? ? [] : path.split('/')
@@ -230,10 +267,18 @@ module RubyRoutes
230
267
  return @defaults if params.empty?
231
268
 
232
269
  merged = get_thread_local_merged_hash
270
+
271
+ # Merge defaults first if they exist
233
272
  merged.update(@defaults) unless @defaults.empty?
234
273
 
235
- # Convert param keys to strings efficiently
236
- params.each { |k, v| merged[k.to_s] = v }
274
+ # Use merge! with transform_keys for better performance
275
+ if params.respond_to?(:transform_keys)
276
+ merged.merge!(params.transform_keys(&:to_s))
277
+ else
278
+ # Fallback for older Ruby versions
279
+ params.each { |k, v| merged[k.to_s] = v }
280
+ end
281
+
237
282
  merged
238
283
  end
239
284
 
@@ -245,67 +290,62 @@ module RubyRoutes
245
290
 
246
291
  # Fast cache key building with minimal allocations
247
292
  def build_cache_key_fast(merged)
248
- # Use instance variable buffer to avoid repeated allocations
249
- @cache_key_buffer ||= String.new(capacity: 128)
250
- @cache_key_buffer.clear
251
-
252
- return @cache_key_buffer.dup if @required_params.empty?
293
+ return '' if @required_params.empty?
253
294
 
254
- @required_params.each_with_index do |name, idx|
255
- @cache_key_buffer << '|' unless idx.zero?
295
+ # Use array join which is faster than string concatenation
296
+ parts = @required_params.map do |name|
256
297
  value = merged[name]
257
- @cache_key_buffer << (value.is_a?(Array) ? value.join('/') : value.to_s) if value
298
+ value.is_a?(Array) ? value.join('/') : value.to_s
258
299
  end
259
- @cache_key_buffer.dup
300
+ parts.join('|')
260
301
  end
261
302
 
262
303
  # Optimized path generation
263
304
  def generate_path_string(merged)
264
305
  return ROOT_PATH if @compiled_segments.empty?
265
306
 
266
- buffer = String.new(capacity: 128)
267
- buffer << '/'
268
-
269
- @compiled_segments.each_with_index do |seg, idx|
270
- buffer << '/' unless idx.zero?
307
+ # Pre-allocate array for parts to avoid string buffer operations
308
+ parts = []
271
309
 
310
+ @compiled_segments.each do |seg|
272
311
  case seg[:type]
273
312
  when :static
274
- buffer << seg[:value]
313
+ parts << seg[:value]
275
314
  when :param
276
315
  value = merged.fetch(seg[:name]).to_s
277
- buffer << encode_segment_fast(value)
316
+ parts << encode_segment_fast(value)
278
317
  when :splat
279
318
  value = merged.fetch(seg[:name], '')
280
- append_splat_value(buffer, value)
319
+ parts << format_splat_value(value)
281
320
  end
282
321
  end
283
322
 
284
- buffer == '/' ? ROOT_PATH : buffer
323
+ # Single join operation is faster than multiple string concatenations
324
+ path = "/#{parts.join('/')}"
325
+ path == '/' ? ROOT_PATH : path
285
326
  end
286
327
 
287
- def append_splat_value(buffer, value)
328
+ def format_splat_value(value)
288
329
  case value
289
330
  when Array
290
- value.each_with_index do |part, idx|
291
- buffer << '/' unless idx.zero?
292
- buffer << encode_segment_fast(part.to_s)
293
- end
331
+ value.map { |part| encode_segment_fast(part.to_s) }.join('/')
294
332
  when String
295
- parts = value.split('/')
296
- parts.each_with_index do |part, idx|
297
- buffer << '/' unless idx.zero?
298
- buffer << encode_segment_fast(part)
299
- end
333
+ value.split('/').map { |part| encode_segment_fast(part) }.join('/')
300
334
  else
301
- buffer << encode_segment_fast(value.to_s)
335
+ encode_segment_fast(value.to_s)
302
336
  end
303
337
  end
304
338
 
305
- # Fast segment encoding
339
+ # Fast segment encoding with caching for common values
306
340
  def encode_segment_fast(str)
307
341
  return str if UNRESERVED_RE.match?(str)
308
- URI.encode_www_form_component(str)
342
+
343
+ # Cache encoded segments to avoid repeated encoding
344
+ @encoding_cache ||= {}
345
+ @encoding_cache[str] ||= begin
346
+ # Use URI.encode_www_form_component but replace + with %20 for path segments
347
+ URI.encode_www_form_component(str).gsub('+', '%20')
348
+ end
309
349
  end
310
350
 
311
351
  # Optimized query params with caching
@@ -314,7 +354,7 @@ module RubyRoutes
314
354
  return EMPTY_HASH unless query_start
315
355
 
316
356
  query_string = path[(query_start + 1)..-1]
317
- return EMPTY_HASH if query_string.empty?
357
+ return EMPTY_HASH if query_string.empty? || query_string.match?(/^\?+$/)
318
358
 
319
359
  # Cache query param parsing
320
360
  if (cached = @query_cache.get(query_string))
@@ -348,22 +388,110 @@ module RubyRoutes
348
388
  def validate_constraints_fast!(params)
349
389
  @constraints.each do |param, constraint|
350
390
  value = params[param.to_s]
351
- next unless value
391
+ # Only skip validation if the parameter is completely missing from params
392
+ # Empty strings and nil values should still be validated
393
+ next unless params.key?(param.to_s)
352
394
 
353
395
  case constraint
354
396
  when Regexp
355
- raise ConstraintViolation unless constraint.match?(value)
397
+ # Protect against ReDoS attacks with timeout
398
+ begin
399
+ Timeout.timeout(0.1) do
400
+ raise RubyRoutes::ConstraintViolation unless constraint.match?(value.to_s)
401
+ end
402
+ rescue Timeout::Error
403
+ raise RubyRoutes::ConstraintViolation, "Regex constraint timed out (potential ReDoS attack)"
404
+ end
356
405
  when Proc
357
- raise ConstraintViolation unless constraint.call(value)
406
+ # DEPRECATED: Proc constraints are deprecated due to security risks
407
+ warn_proc_constraint_deprecation(param)
408
+
409
+ # For backward compatibility, still execute but with strict timeout
410
+ begin
411
+ Timeout.timeout(0.05) do # Reduced timeout for security
412
+ raise RubyRoutes::ConstraintViolation unless constraint.call(value.to_s)
413
+ end
414
+ rescue Timeout::Error
415
+ raise RubyRoutes::ConstraintViolation, "Proc constraint timed out (consider using secure alternatives)"
416
+ rescue => e
417
+ raise RubyRoutes::ConstraintViolation, "Proc constraint failed: #{e.message}"
418
+ end
358
419
  when :int
359
- raise ConstraintViolation unless value.match?(/\A\d+\z/)
420
+ value_str = value.to_s
421
+ raise RubyRoutes::ConstraintViolation unless value_str.match?(/\A\d+\z/)
360
422
  when :uuid
361
- raise ConstraintViolation unless value.length == 36 &&
362
- value.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
423
+ value_str = value.to_s
424
+ raise RubyRoutes::ConstraintViolation unless value_str.length == 36 &&
425
+ 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)
426
+ when :email
427
+ value_str = value.to_s
428
+ raise RubyRoutes::ConstraintViolation unless value_str.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
429
+ when :slug
430
+ value_str = value.to_s
431
+ raise RubyRoutes::ConstraintViolation unless value_str.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/)
432
+ when :alpha
433
+ value_str = value.to_s
434
+ raise RubyRoutes::ConstraintViolation unless value_str.match?(/\A[a-zA-Z]+\z/)
435
+ when :alphanumeric
436
+ value_str = value.to_s
437
+ raise RubyRoutes::ConstraintViolation unless value_str.match?(/\A[a-zA-Z0-9]+\z/)
438
+ when Hash
439
+ # Secure hash-based constraints for common patterns
440
+ validate_hash_constraint!(constraint, value_str = value.to_s)
363
441
  end
364
442
  end
365
443
  end
366
444
 
445
+ def warn_proc_constraint_deprecation(param)
446
+ return if @proc_warnings_shown&.include?(param)
447
+
448
+ @proc_warnings_shown ||= Set.new
449
+ @proc_warnings_shown << param
450
+
451
+ warn <<~WARNING
452
+ [DEPRECATION] Proc constraints are deprecated due to security risks.
453
+
454
+ Parameter: #{param}
455
+ Route: #{@path}
456
+
457
+ Secure alternatives:
458
+ - Use regex: constraints: { #{param}: /\\A\\d+\\z/ }
459
+ - Use built-in types: constraints: { #{param}: :int }
460
+ - Use hash constraints: constraints: { #{param}: { min_length: 3, format: /\\A[a-z]+\\z/ } }
461
+
462
+ Available built-in types: :int, :uuid, :email, :slug, :alpha, :alphanumeric
463
+
464
+ This warning will become an error in a future version.
465
+ WARNING
466
+ end
467
+
468
+ def validate_hash_constraint!(constraint, value)
469
+ # Secure hash-based constraints
470
+ if constraint[:min_length] && value.length < constraint[:min_length]
471
+ raise RubyRoutes::ConstraintViolation, "Value too short (minimum #{constraint[:min_length]} characters)"
472
+ end
473
+
474
+ if constraint[:max_length] && value.length > constraint[:max_length]
475
+ raise RubyRoutes::ConstraintViolation, "Value too long (maximum #{constraint[:max_length]} characters)"
476
+ end
477
+
478
+ if constraint[:format] && !value.match?(constraint[:format])
479
+ raise RubyRoutes::ConstraintViolation, "Value does not match required format"
480
+ end
481
+
482
+ if constraint[:in] && !constraint[:in].include?(value)
483
+ raise RubyRoutes::ConstraintViolation, "Value not in allowed list"
484
+ end
485
+
486
+ if constraint[:not_in] && constraint[:not_in].include?(value)
487
+ raise RubyRoutes::ConstraintViolation, "Value in forbidden list"
488
+ end
489
+
490
+ if constraint[:range] && !constraint[:range].cover?(value.to_i)
491
+ raise RubyRoutes::ConstraintViolation, "Value not in allowed range"
492
+ end
493
+ end
494
+
367
495
  def validate_route!
368
496
  raise InvalidRoute, "Controller is required" if @controller.nil?
369
497
  raise InvalidRoute, "Action is required" if @action.nil?
@@ -42,16 +42,10 @@ module RubyRoutes
42
42
  # Optimized cache key: avoid string interpolation when possible
43
43
  cache_key = build_cache_key(method_up, request_path)
44
44
 
45
- # Cache hit: return immediately
46
- if (cached = @recognition_cache[cache_key])
45
+ # Cache hit: return immediately (cached result includes full structure)
46
+ if (cached_result = @recognition_cache[cache_key])
47
47
  @cache_hits += 1
48
- cached_route, cached_params = cached
49
- return {
50
- route: cached_route,
51
- params: cached_params,
52
- controller: cached_route.controller,
53
- action: cached_route.action
54
- }
48
+ return cached_result
55
49
  end
56
50
 
57
51
  @cache_misses += 1
@@ -71,17 +65,17 @@ module RubyRoutes
71
65
  merge_query_params(route, request_path, params)
72
66
  end
73
67
 
74
- # Create return hash and cache entry
68
+ # Create return hash and cache the complete result
75
69
  result_params = params.dup
76
- cache_entry = [route, result_params.freeze]
77
- insert_cache_entry(cache_key, cache_entry)
78
-
79
- {
70
+ result = {
80
71
  route: route,
81
72
  params: result_params,
82
73
  controller: route.controller,
83
74
  action: route.action
84
- }
75
+ }.freeze
76
+
77
+ insert_cache_entry(cache_key, result)
78
+ result
85
79
  end
86
80
 
87
81
  def recognize_path(path, method = :get)
@@ -141,7 +135,7 @@ module RubyRoutes
141
135
 
142
136
  private
143
137
 
144
- # Method lookup table to avoid repeated upcasing
138
+ # Method lookup table to avoid repeated upcasing with interned strings
145
139
  def method_lookup(method)
146
140
  @method_cache ||= Hash.new { |h, k| h[k] = k.to_s.upcase.freeze }
147
141
  @method_cache[method]
@@ -149,29 +143,20 @@ module RubyRoutes
149
143
 
150
144
  # Optimized cache key building - avoid string interpolation
151
145
  def build_cache_key(method, path)
152
- # Use frozen string concatenation to avoid allocations
153
- @cache_key_buffer ||= String.new(capacity: 256)
154
- @cache_key_buffer.clear
155
- @cache_key_buffer << method << ':' << path
156
- @cache_key_buffer.dup.freeze
146
+ # String interpolation creates a new string directly without intermediate allocations
147
+ "#{method}:#{path}"
157
148
  end
158
149
 
159
150
  # Get thread-local params hash, reusing when possible
160
151
  def get_thread_local_params
161
- # Use object pool to reduce GC pressure
162
- @params_pool ||= []
163
- if @params_pool.empty?
164
- {}
165
- else
166
- hash = @params_pool.pop
167
- hash.clear
168
- hash
169
- end
152
+ # Use single thread-local hash that gets cleared, avoiding pool management overhead
153
+ hash = Thread.current[:ruby_routes_params_hash] ||= {}
154
+ hash.clear
155
+ hash
170
156
  end
171
157
 
172
158
  def return_params_to_pool(params)
173
- @params_pool ||= []
174
- @params_pool.push(params) if @params_pool.size < 10
159
+ # No-op since we're using a single reusable hash per thread
175
160
  end
176
161
 
177
162
  # Fast defaults merging
@@ -5,6 +5,7 @@ module RubyRoutes
5
5
  def initialize(&block)
6
6
  @route_set = RouteSet.new
7
7
  @scope_stack = []
8
+ @concerns = {}
8
9
  instance_eval(&block) if block_given?
9
10
  end
10
11
 
@@ -36,19 +37,20 @@ module RubyRoutes
36
37
  # Resources routing (Rails-like)
37
38
  def resources(name, options = {}, &block)
38
39
  singular = name.to_s.singularize
39
- plural = name.to_s.pluralize
40
+ plural = (options[:path] || name.to_s.pluralize)
41
+ controller = options[:controller] || plural
40
42
 
41
43
  # Collection routes
42
- get "/#{plural}", options.merge(to: "#{plural}#index")
43
- get "/#{plural}/new", options.merge(to: "#{plural}#new")
44
- post "/#{plural}", options.merge(to: "#{plural}#create")
44
+ get "/#{plural}", options.merge(to: "#{controller}#index")
45
+ get "/#{plural}/new", options.merge(to: "#{controller}#new")
46
+ post "/#{plural}", options.merge(to: "#{controller}#create")
45
47
 
46
48
  # Member routes
47
- get "/#{plural}/:id", options.merge(to: "#{plural}#show")
48
- get "/#{plural}/:id/edit", options.merge(to: "#{plural}#edit")
49
- put "/#{plural}/:id", options.merge(to: "#{plural}#update")
50
- patch "/#{plural}/:id", options.merge(to: "#{plural}#update")
51
- delete "/#{plural}/:id", options.merge(to: "#{plural}#destroy")
49
+ get "/#{plural}/:id", options.merge(to: "#{controller}#show")
50
+ get "/#{plural}/:id/edit", options.merge(to: "#{controller}#edit")
51
+ put "/#{plural}/:id", options.merge(to: "#{controller}#update")
52
+ patch "/#{plural}/:id", options.merge(to: "#{controller}#update")
53
+ delete "/#{plural}/:id", options.merge(to: "#{controller}#destroy")
52
54
 
53
55
  # Nested resources if specified
54
56
  if options[:nested]
@@ -63,7 +65,6 @@ module RubyRoutes
63
65
  get "/#{plural}/:id/#{nested_plural}/:nested_id/edit", options.merge(to: "#{nested_plural}#edit")
64
66
  put "/#{plural}/:id/#{nested_plural}/:nested_id", options.merge(to: "#{nested_plural}#update")
65
67
  patch "/#{plural}/:id/#{nested_plural}/:nested_id", options.merge(to: "#{nested_plural}#update")
66
- delete "/#{plural}/:id/#{nested_plural}/:nested_id", options.merge(to: "#{nested_plural}#update")
67
68
  delete "/#{plural}/:id/#{nested_plural}/:nested_id", options.merge(to: "#{nested_plural}#destroy")
68
69
  end
69
70
 
@@ -131,7 +132,6 @@ module RubyRoutes
131
132
  end
132
133
 
133
134
  def concern(name, &block)
134
- @concerns ||= {}
135
135
  @concerns[name] = block
136
136
  end
137
137
 
@@ -14,8 +14,10 @@ class String
14
14
  case self
15
15
  when /y$/
16
16
  self.sub(/y$/, 'ies')
17
- when /sh$/, /ch$/, /x$/, /z$/
17
+ when /sh$/, /ch$/, /x$/
18
18
  self + 'es'
19
+ when /z$/
20
+ self + 'zes'
19
21
  when /s$/
20
22
  # Words ending in 's' are already plural
21
23
  self
@@ -1,7 +1,10 @@
1
+ require 'cgi'
2
+
1
3
  module RubyRoutes
2
4
  module UrlHelpers
3
5
  def self.included(base)
4
6
  base.extend(ClassMethods)
7
+ base.include(base.url_helpers)
5
8
  end
6
9
 
7
10
  module ClassMethods
@@ -33,16 +36,33 @@ module RubyRoutes
33
36
 
34
37
  def link_to(name, text, params = {})
35
38
  path = path_to(name, params)
36
- "<a href=\"#{path}\">#{text}</a>"
39
+ safe_path = CGI.escapeHTML(path.to_s)
40
+ safe_text = CGI.escapeHTML(text.to_s)
41
+ "<a href=\"#{safe_path}\">#{safe_text}</a>"
37
42
  end
38
43
 
39
44
  def button_to(name, text, params = {})
40
- path = path_to(name, params)
41
- method = params.delete(:method) || :post
45
+ local_params = params ? params.dup : {}
46
+ method = local_params.delete(:method) || :post
47
+ method = method.to_s.downcase
48
+ path = path_to(name, local_params)
49
+
50
+ # HTML forms only support GET and POST
51
+ # For other methods, use POST with _method hidden field
52
+ form_method = (method == 'get') ? 'get' : 'post'
53
+
54
+ safe_path = CGI.escapeHTML(path.to_s)
55
+ safe_form_method = CGI.escapeHTML(form_method)
56
+ html = "<form action=\"#{safe_path}\" method=\"#{safe_form_method}\">"
57
+
58
+ # Add _method hidden field for non-GET/POST methods
59
+ if method != 'get' && method != 'post'
60
+ safe_method = CGI.escapeHTML(method)
61
+ html += "<input type=\"hidden\" name=\"_method\" value=\"#{safe_method}\">"
62
+ end
42
63
 
43
- html = "<form action=\"#{path}\" method=\"#{method}\">"
44
- html += "<input type=\"hidden\" name=\"_method\" value=\"#{method}\">" if method != :get
45
- html += "<button type=\"submit\">#{text}</button>"
64
+ safe_text = CGI.escapeHTML(text.to_s)
65
+ html += "<button type=\"submit\">#{safe_text}</button>"
46
66
  html += "</form>"
47
67
  html
48
68
  end
@@ -1,3 +1,3 @@
1
1
  module RubyRoutes
2
- VERSION = "1.1.0"
2
+ VERSION = "2.1.0"
3
3
  end
data/lib/ruby_routes.rb CHANGED
@@ -11,6 +11,7 @@ module RubyRoutes
11
11
  class Error < StandardError; end
12
12
  class RouteNotFound < Error; end
13
13
  class InvalidRoute < Error; end
14
+ class ConstraintViolation < Error; end
14
15
 
15
16
  # Create a new router instance
16
17
  def self.new(&block)
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: 1.1.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yosef Benny Widyokarsono
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rack
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.2'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.2'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: simplecov
42
56
  requirement: !ruby/object:Gem::Requirement