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 +4 -4
- data/README.md +110 -6
- data/lib/ruby_routes/constant.rb +4 -3
- data/lib/ruby_routes/radix_tree.rb +7 -2
- data/lib/ruby_routes/route.rb +185 -57
- data/lib/ruby_routes/route_set.rb +17 -32
- data/lib/ruby_routes/router.rb +11 -11
- data/lib/ruby_routes/string_extensions.rb +3 -1
- data/lib/ruby_routes/url_helpers.rb +26 -6
- data/lib/ruby_routes/version.rb +1 -1
- data/lib/ruby_routes.rb +1 -0
- metadata +15 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: fb6b2a38cb8f31bb3e79fa66dbbb1a0620c6eec1f668680e152efe38e7926b42
|
4
|
+
data.tar.gz: db5d62093e15a422d85e6c7ec373327ca1f37fd24f7b851c12bdb2f70cf81622
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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**:
|
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
|
-
###
|
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
|
-
|
124
|
-
|
125
|
-
|
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
|
-
##
|
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
|
data/lib/ruby_routes/constant.rb
CHANGED
@@ -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
|
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(
|
54
|
+
segments = split_path_cached(clean_path)
|
50
55
|
current = @root
|
51
56
|
params = params_out || {}
|
52
57
|
params.clear if params_out
|
data/lib/ruby_routes/route.rb
CHANGED
@@ -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
|
-
#
|
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
|
103
|
-
when :post then
|
104
|
-
when :put then
|
105
|
-
when :patch then
|
106
|
-
when :delete then
|
107
|
-
when :head then
|
108
|
-
when :options then
|
109
|
-
else method.to_s.upcase
|
110
|
-
end
|
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
|
-
|
182
|
-
|
183
|
-
|
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
|
-
|
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
|
-
#
|
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
|
-
#
|
236
|
-
params.
|
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
|
-
|
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
|
-
|
255
|
-
|
295
|
+
# Use array join which is faster than string concatenation
|
296
|
+
parts = @required_params.map do |name|
|
256
297
|
value = merged[name]
|
257
|
-
|
298
|
+
value.is_a?(Array) ? value.join('/') : value.to_s
|
258
299
|
end
|
259
|
-
|
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
|
-
|
267
|
-
|
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
|
-
|
313
|
+
parts << seg[:value]
|
275
314
|
when :param
|
276
315
|
value = merged.fetch(seg[:name]).to_s
|
277
|
-
|
316
|
+
parts << encode_segment_fast(value)
|
278
317
|
when :splat
|
279
318
|
value = merged.fetch(seg[:name], '')
|
280
|
-
|
319
|
+
parts << format_splat_value(value)
|
281
320
|
end
|
282
321
|
end
|
283
322
|
|
284
|
-
|
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
|
328
|
+
def format_splat_value(value)
|
288
329
|
case value
|
289
330
|
when Array
|
290
|
-
value.
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
420
|
+
value_str = value.to_s
|
421
|
+
raise RubyRoutes::ConstraintViolation unless value_str.match?(/\A\d+\z/)
|
360
422
|
when :uuid
|
361
|
-
|
362
|
-
|
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 (
|
45
|
+
# Cache hit: return immediately (cached result includes full structure)
|
46
|
+
if (cached_result = @recognition_cache[cache_key])
|
47
47
|
@cache_hits += 1
|
48
|
-
|
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
|
68
|
+
# Create return hash and cache the complete result
|
75
69
|
result_params = params.dup
|
76
|
-
|
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
|
-
#
|
153
|
-
|
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
|
162
|
-
|
163
|
-
|
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
|
-
|
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
|
data/lib/ruby_routes/router.rb
CHANGED
@@ -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: "#{
|
43
|
-
get "/#{plural}/new", options.merge(to: "#{
|
44
|
-
post "/#{plural}", options.merge(to: "#{
|
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: "#{
|
48
|
-
get "/#{plural}/:id/edit", options.merge(to: "#{
|
49
|
-
put "/#{plural}/:id", options.merge(to: "#{
|
50
|
-
patch "/#{plural}/:id", options.merge(to: "#{
|
51
|
-
delete "/#{plural}/:id", options.merge(to: "#{
|
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
|
|
@@ -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
|
-
|
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
|
-
|
41
|
-
method =
|
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
|
-
|
44
|
-
html += "<
|
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
|
data/lib/ruby_routes/version.rb
CHANGED
data/lib/ruby_routes.rb
CHANGED
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:
|
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
|