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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4017abab11dd0945bcfe1659b2ed1456c2cf82a8993db55d094d283c48c8ebd0
|
4
|
+
data.tar.gz: 07f0aba728bf81cdfdc245e20a212f1775b2314a826c88f0e25b8ff2b608c195
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a4aa77ba441b9e87cdcb31cae5b5f12babaf93396e400723d5f845392ac132520c4398e19d49d0755e05fc305791b54a3676a460ba8c73cd4b5ee55647fb2589
|
7
|
+
data.tar.gz: a3b883c253731dfad5eaf34a28fb89684d0f9fa921127f6e428225743a93d87934508e49f59c3710964cc8a444b729f9278872aa8d991c4302abbb7ff8645b66
|
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
|
@@ -62,7 +62,6 @@ end
|
|
62
62
|
```
|
63
63
|
|
64
64
|
This creates the following routes:
|
65
|
-
|
66
65
|
- `GET /users` → `users#index`
|
67
66
|
- `GET /users/new` → `users#new`
|
68
67
|
- `POST /users` → `users#create`
|
@@ -117,14 +116,91 @@ end
|
|
117
116
|
# etc.
|
118
117
|
```
|
119
118
|
|
120
|
-
###
|
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
|
121
124
|
|
122
125
|
```ruby
|
123
126
|
router = RubyRoutes.draw do
|
124
|
-
|
125
|
-
|
126
|
-
|
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 }
|
127
132
|
|
133
|
+
# Email validation
|
134
|
+
get '/users/:email', to: 'users#show', constraints: { email: :email }
|
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
|
128
204
|
scope defaults: { format: 'html' } do
|
129
205
|
get '/posts', to: 'posts#index'
|
130
206
|
end
|
@@ -274,13 +350,38 @@ Creates a new router instance and yields to the block for route definition.
|
|
274
350
|
- `find_route(method, path)` - Finds a specific route
|
275
351
|
- `find_named_route(name)` - Finds a named route
|
276
352
|
|
277
|
-
##
|
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
|
278
360
|
|
279
361
|
See the `examples/` directory for more detailed examples:
|
280
362
|
|
281
363
|
- `examples/basic_usage.rb` - Basic routing examples
|
282
364
|
- `examples/rack_integration.rb` - Full Rack application example
|
283
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
|
+
|
284
385
|
## Testing
|
285
386
|
|
286
387
|
Run the test suite:
|
@@ -289,6 +390,8 @@ Run the test suite:
|
|
289
390
|
bundle exec rspec
|
290
391
|
```
|
291
392
|
|
393
|
+
The test suite includes comprehensive security tests to ensure all protections are working correctly.
|
394
|
+
|
292
395
|
## Contributing
|
293
396
|
|
294
397
|
1. Fork the repository
|
data/lib/ruby_routes/constant.rb
CHANGED
@@ -42,9 +42,9 @@ module RubyRoutes
|
|
42
42
|
|
43
43
|
# Descriptor factories for segment classification (O(1) dispatch by first byte).
|
44
44
|
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 } }
|
45
|
+
42 => ->(s) { { type: :splat, name: (s[1..-1] || 'splat').freeze } }, # '*'
|
46
|
+
58 => ->(s) { { type: :param, name: s[1..-1].freeze } }, # ':'
|
47
|
+
:default => ->(s) { { type: :static, value: s.freeze } } # Intern static values
|
48
48
|
}.freeze
|
49
49
|
|
50
50
|
def self.segment_descriptor(raw)
|
data/lib/ruby_routes/node.rb
CHANGED
@@ -14,43 +14,52 @@ module RubyRoutes
|
|
14
14
|
@is_endpoint = false
|
15
15
|
end
|
16
16
|
|
17
|
-
#
|
17
|
+
# Fast traversal: minimal allocations, streamlined branching
|
18
18
|
# Returns [next_node_or_nil, should_break_bool] or [nil, false] if no match.
|
19
19
|
def traverse_for(segment, index, segments, params)
|
20
|
-
#
|
21
|
-
|
22
|
-
|
23
|
-
end
|
20
|
+
# Static match: O(1) hash lookup
|
21
|
+
child = @static_children[segment]
|
22
|
+
return [child, false] if child
|
24
23
|
|
25
|
-
#
|
26
|
-
if @dynamic_child
|
27
|
-
|
28
|
-
|
29
|
-
params[next_node.param_name.to_s] = segment
|
30
|
-
end
|
31
|
-
return [next_node, false]
|
24
|
+
# Dynamic match: single segment capture
|
25
|
+
if (dyn = @dynamic_child)
|
26
|
+
params[dyn.param_name] = segment if params
|
27
|
+
return [dyn, false]
|
32
28
|
end
|
33
29
|
|
34
|
-
#
|
35
|
-
if @wildcard_child
|
36
|
-
next_node = @wildcard_child
|
30
|
+
# Wildcard match: consume remainder (last resort)
|
31
|
+
if (wild = @wildcard_child)
|
37
32
|
if params
|
38
|
-
|
33
|
+
# Build remainder path without intermediate array allocation
|
34
|
+
remainder = segments[index..-1]
|
35
|
+
params[wild.param_name] = remainder.size == 1 ? remainder[0] : remainder.join('/')
|
39
36
|
end
|
40
|
-
return [
|
37
|
+
return [wild, true]
|
41
38
|
end
|
42
39
|
|
43
|
-
# No match
|
40
|
+
# No match
|
44
41
|
[nil, false]
|
45
42
|
end
|
46
43
|
|
44
|
+
# Pre-cache param names as strings to avoid repeated .to_s calls
|
45
|
+
def param_name
|
46
|
+
@param_name_str ||= @param_name&.to_s
|
47
|
+
end
|
48
|
+
|
49
|
+
def param_name=(name)
|
50
|
+
@param_name = name
|
51
|
+
@param_name_str = nil # invalidate cache
|
52
|
+
end
|
53
|
+
|
54
|
+
# Normalize method once and cache string keys
|
47
55
|
def add_handler(method, handler)
|
48
|
-
|
56
|
+
method_key = method.to_s.upcase
|
57
|
+
@handlers[method_key] = handler
|
49
58
|
@is_endpoint = true
|
50
59
|
end
|
51
60
|
|
52
61
|
def get_handler(method)
|
53
|
-
@handlers[method
|
62
|
+
@handlers[method] # assume already normalized upstream
|
54
63
|
end
|
55
64
|
end
|
56
65
|
end
|
@@ -4,12 +4,8 @@ module RubyRoutes
|
|
4
4
|
class RadixTree
|
5
5
|
class << self
|
6
6
|
# Allow RadixTree.new(path, options...) to act as a convenience factory
|
7
|
-
# returning a Route (this matches test usage where specs call
|
8
|
-
# RadixTree.new('/path', to: 'controller#action')).
|
9
|
-
# Calling RadixTree.new with no arguments returns an actual RadixTree instance.
|
10
7
|
def new(*args, &block)
|
11
8
|
if args.any?
|
12
|
-
# Delegate to Route initializer when args are provided
|
13
9
|
RubyRoutes::Route.new(*args, &block)
|
14
10
|
else
|
15
11
|
super()
|
@@ -19,47 +15,85 @@ module RubyRoutes
|
|
19
15
|
|
20
16
|
def initialize
|
21
17
|
@root = Node.new
|
22
|
-
@
|
23
|
-
@split_cache_order = []
|
24
|
-
@split_cache_max =
|
18
|
+
@split_cache = {}
|
19
|
+
@split_cache_order = []
|
20
|
+
@split_cache_max = 2048 # larger cache for better hit rates
|
21
|
+
@empty_segments = [].freeze # reuse for root path
|
25
22
|
end
|
26
23
|
|
27
24
|
def add(path, methods, handler)
|
28
25
|
current = @root
|
29
|
-
|
26
|
+
segments = split_path_raw(path)
|
27
|
+
|
28
|
+
segments.each do |raw_seg|
|
29
|
+
seg = RubyRoutes::Segment.for(raw_seg)
|
30
30
|
current = seg.ensure_child(current)
|
31
31
|
break if seg.wildcard?
|
32
32
|
end
|
33
33
|
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
def parse_segments(path)
|
38
|
-
split_path(path).map { |s| RubyRoutes::Segment.for(s) }
|
34
|
+
# Normalize methods once during registration
|
35
|
+
Array(methods).each { |method| current.add_handler(method.to_s.upcase, handler) }
|
39
36
|
end
|
40
37
|
|
41
38
|
def find(path, method, params_out = nil)
|
42
|
-
|
39
|
+
# Fast path: root route
|
40
|
+
if path == '/' || path.empty?
|
41
|
+
handler = @root.get_handler(method)
|
42
|
+
if @root.is_endpoint && handler
|
43
|
+
return [handler, params_out || {}]
|
44
|
+
else
|
45
|
+
return [nil, {}]
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
segments = split_path_cached(path)
|
43
50
|
current = @root
|
44
51
|
params = params_out || {}
|
45
52
|
params.clear if params_out
|
46
53
|
|
47
|
-
|
48
|
-
|
54
|
+
# Unrolled traversal for common case (1-3 segments)
|
55
|
+
case segments.size
|
56
|
+
when 1
|
57
|
+
next_node, _ = current.traverse_for(segments[0], 0, segments, params)
|
58
|
+
current = next_node
|
59
|
+
when 2
|
60
|
+
next_node, should_break = current.traverse_for(segments[0], 0, segments, params)
|
61
|
+
return [nil, {}] unless next_node
|
62
|
+
current = next_node
|
63
|
+
unless should_break
|
64
|
+
next_node, _ = current.traverse_for(segments[1], 1, segments, params)
|
65
|
+
current = next_node
|
66
|
+
end
|
67
|
+
when 3
|
68
|
+
next_node, should_break = current.traverse_for(segments[0], 0, segments, params)
|
49
69
|
return [nil, {}] unless next_node
|
50
70
|
current = next_node
|
51
|
-
|
71
|
+
unless should_break
|
72
|
+
next_node, should_break = current.traverse_for(segments[1], 1, segments, params)
|
73
|
+
return [nil, {}] unless next_node
|
74
|
+
current = next_node
|
75
|
+
unless should_break
|
76
|
+
next_node, _ = current.traverse_for(segments[2], 2, segments, params)
|
77
|
+
current = next_node
|
78
|
+
end
|
79
|
+
end
|
80
|
+
else
|
81
|
+
# General case for longer paths
|
82
|
+
segments.each_with_index do |text, idx|
|
83
|
+
next_node, should_break = current.traverse_for(text, idx, segments, params)
|
84
|
+
return [nil, {}] unless next_node
|
85
|
+
current = next_node
|
86
|
+
break if should_break
|
87
|
+
end
|
52
88
|
end
|
53
89
|
|
90
|
+
return [nil, {}] unless current
|
54
91
|
handler = current.get_handler(method)
|
55
92
|
return [nil, {}] unless current.is_endpoint && handler
|
56
93
|
|
57
|
-
#
|
58
|
-
|
59
|
-
|
60
|
-
unless constraints_match?(route.constraints, params)
|
61
|
-
return [nil, {}]
|
62
|
-
end
|
94
|
+
# Fast constraint check
|
95
|
+
if handler.respond_to?(:constraints) && !handler.constraints.empty?
|
96
|
+
return [nil, {}] unless constraints_match_fast(handler.constraints, params)
|
63
97
|
end
|
64
98
|
|
65
99
|
[handler, params]
|
@@ -67,34 +101,48 @@ module RubyRoutes
|
|
67
101
|
|
68
102
|
private
|
69
103
|
|
70
|
-
#
|
71
|
-
def
|
72
|
-
@
|
73
|
-
|
104
|
+
# Cached path splitting with optimized common cases
|
105
|
+
def split_path_cached(path)
|
106
|
+
return @empty_segments if path == '/' || path.empty?
|
107
|
+
|
74
108
|
if (cached = @split_cache[path])
|
75
109
|
return cached
|
76
110
|
end
|
77
111
|
|
78
|
-
|
79
|
-
p = p[1..-1] if p.start_with?('/')
|
80
|
-
p = p[0...-1] if p.end_with?('/')
|
81
|
-
segs = p.split('/')
|
112
|
+
result = split_path_raw(path)
|
82
113
|
|
83
|
-
# simple LRU
|
84
|
-
@split_cache[path] =
|
114
|
+
# Cache with simple LRU eviction
|
115
|
+
@split_cache[path] = result
|
85
116
|
@split_cache_order << path
|
86
117
|
if @split_cache_order.size > @split_cache_max
|
87
118
|
oldest = @split_cache_order.shift
|
88
119
|
@split_cache.delete(oldest)
|
89
120
|
end
|
90
121
|
|
91
|
-
|
122
|
+
result
|
123
|
+
end
|
124
|
+
|
125
|
+
# Raw path splitting without caching (for registration)
|
126
|
+
def split_path_raw(path)
|
127
|
+
return [] if path == '/' || path.empty?
|
128
|
+
|
129
|
+
# Optimized trimming: avoid string allocations when possible
|
130
|
+
start_idx = path.start_with?('/') ? 1 : 0
|
131
|
+
end_idx = path.end_with?('/') ? -2 : -1
|
132
|
+
|
133
|
+
if start_idx == 0 && end_idx == -1
|
134
|
+
path.split('/')
|
135
|
+
else
|
136
|
+
path[start_idx..end_idx].split('/')
|
137
|
+
end
|
92
138
|
end
|
93
139
|
|
94
|
-
#
|
95
|
-
def
|
140
|
+
# Optimized constraint matching with fast paths
|
141
|
+
def constraints_match_fast(constraints, params)
|
96
142
|
constraints.each do |param, constraint|
|
97
|
-
|
143
|
+
# Try both string and symbol keys (common pattern)
|
144
|
+
value = params[param.to_s]
|
145
|
+
value ||= params[param] if param.respond_to?(:to_s)
|
98
146
|
next unless value
|
99
147
|
|
100
148
|
case constraint
|
@@ -102,15 +150,16 @@ module RubyRoutes
|
|
102
150
|
return false unless constraint.match?(value)
|
103
151
|
when Proc
|
104
152
|
return false unless constraint.call(value)
|
153
|
+
when :int
|
154
|
+
# Fast integer check without regex
|
155
|
+
return false unless value.is_a?(String) && value.match?(/\A\d+\z/)
|
156
|
+
when :uuid
|
157
|
+
# Fast UUID check
|
158
|
+
return false unless value.is_a?(String) && value.length == 36 &&
|
159
|
+
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)
|
105
160
|
when Symbol
|
106
|
-
|
107
|
-
|
108
|
-
when :uuid then return false unless value.match?(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
|
109
|
-
else
|
110
|
-
# unknown symbol constraint — be conservative and allow
|
111
|
-
end
|
112
|
-
else
|
113
|
-
# unknown constraint type — allow (Route will validate later if needed)
|
161
|
+
# Handle other symbolic constraints
|
162
|
+
next # unknown symbol constraint — allow
|
114
163
|
end
|
115
164
|
end
|
116
165
|
true
|