ruby_routes 0.2.0 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2bc2a63fd1cf72287ce09440410f609579ef5a3cdb4a33513187599dcb5b9ed7
4
- data.tar.gz: 560ab2b883aeb3e42f74ef20fd675e3a692173724a78451bd5739115803944cf
3
+ metadata.gz: 02d39aae113654256dae0905438ac98a540854a5d131dea6c4b2b606b51bf4ba
4
+ data.tar.gz: ba01a85fc21713387e76c183a41e786106f1a053e2485d294895ac36027d94a5
5
5
  SHA512:
6
- metadata.gz: dc41f27e4af178dd3a76dbed95f8a2df9aed4a041d3af184b76ccb39480a9b7d7facab6e2e30147926a3ca178d6e45b391c942eb9cb6fbdfd2167c794ef7d573
7
- data.tar.gz: eafd4d678f45780a7da280e8e91cfddb7f0abe21cadb248fc5f6517e3632c442d7602363b89685baacdff106588a88c1b62c0180670db681af4b7539cd66c1c2
6
+ metadata.gz: c3b230ef35186d4f96714ba708b933cf7897166a74b7162949a3144aa21dc79aab2446a0b14d1e0504ea30f941146a7e0f1841d5908e6a6175d771a9b4a658ad
7
+ data.tar.gz: 9d476bdbaf46cef516cd51adb218097137863fb12976e98d87a7c8d7f77e3609421d81ba14ed77d67cd59054d4356690d088b619e8e089964cd4fdd6055225ac
data/README.md CHANGED
@@ -62,6 +62,7 @@ end
62
62
  ```
63
63
 
64
64
  This creates the following routes:
65
+
65
66
  - `GET /users` → `users#index`
66
67
  - `GET /users/new` → `users#new`
67
68
  - `POST /users` → `users#create`
@@ -0,0 +1,57 @@
1
+ require_relative 'segments/dynamic_segment'
2
+ require_relative 'segments/static_segment'
3
+ require_relative 'segments/wildcard_segment'
4
+ require_relative 'lru_strategies/hit_strategy'
5
+ require_relative 'lru_strategies/miss_strategy'
6
+
7
+ module RubyRoutes
8
+ module Constant
9
+ SEGMENTS = {
10
+ 42 => RubyRoutes::Segments::WildcardSegment, # '*'
11
+ 58 => RubyRoutes::Segments::DynamicSegment, # ':'
12
+ :default => RubyRoutes::Segments::StaticSegment
13
+ }.freeze
14
+
15
+ SEGMENT_MATCHERS = {
16
+ static: lambda do |node, segment, _idx, _segments, _params|
17
+ child = node.static_children[segment]
18
+ child ? [child, false] : nil
19
+ end,
20
+
21
+ dynamic: lambda do |node, segment, _idx, _segments, params|
22
+ return nil unless node.dynamic_child
23
+ nxt = node.dynamic_child
24
+ params[nxt.param_name.to_s] = segment if params && nxt.param_name
25
+ [nxt, false]
26
+ end,
27
+
28
+ wildcard: lambda do |node, _segment, idx, segments, params|
29
+ return nil unless node.wildcard_child
30
+ nxt = node.wildcard_child
31
+ params[nxt.param_name.to_s] = segments[idx..-1].join('/') if params && nxt.param_name
32
+ [nxt, true]
33
+ end,
34
+
35
+ # default returns nil (no match). RadixTree#find will then return [nil, {}].
36
+ default: lambda { |_node, _segment, _idx, _segments, _params| nil }
37
+ }.freeze
38
+
39
+ # singleton instances to avoid per-LRU allocations
40
+ LRU_HIT_STRATEGY = RubyRoutes::LruStrategies::HitStrategy.new.freeze
41
+ LRU_MISS_STRATEGY = RubyRoutes::LruStrategies::MissStrategy.new.freeze
42
+
43
+ # Descriptor factories for segment classification (O(1) dispatch by first byte).
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 } }
48
+ }.freeze
49
+
50
+ def self.segment_descriptor(raw)
51
+ s = raw.to_s
52
+ key = s.empty? ? :default : s.getbyte(0)
53
+ factory = DESCRIPTOR_FACTORIES[key] || DESCRIPTOR_FACTORIES[:default]
54
+ factory.call(s)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,13 @@
1
+ module RubyRoutes
2
+ module LruStrategies
3
+ class HitStrategy
4
+ def call(lru, key)
5
+ lru.increment_hits
6
+ h = lru.instance_variable_get(:@h)
7
+ val = h.delete(key)
8
+ h[key] = val
9
+ val
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ module RubyRoutes
2
+ module LruStrategies
3
+ class MissStrategy
4
+ def call(lru, _key)
5
+ lru.increment_misses
6
+ nil
7
+ end
8
+ end
9
+ end
10
+ end
@@ -1,3 +1,5 @@
1
+ require_relative 'segment'
2
+
1
3
  module RubyRoutes
2
4
  class Node
3
5
  attr_accessor :static_children, :dynamic_child, :wildcard_child,
@@ -12,6 +14,36 @@ module RubyRoutes
12
14
  @is_endpoint = false
13
15
  end
14
16
 
17
+ # Traverse for a single segment using the matcher registry.
18
+ # Returns [next_node_or_nil, should_break_bool] or [nil, false] if no match.
19
+ def traverse_for(segment, index, segments, params)
20
+ # Prefer static children first (exact match).
21
+ if @static_children.key?(segment)
22
+ return [@static_children[segment], false]
23
+ end
24
+
25
+ # Then dynamic param child (single segment)
26
+ if @dynamic_child
27
+ next_node = @dynamic_child
28
+ if params
29
+ params[next_node.param_name.to_s] = segment
30
+ end
31
+ return [next_node, false]
32
+ end
33
+
34
+ # Then wildcard child (consume remainder)
35
+ if @wildcard_child
36
+ next_node = @wildcard_child
37
+ if params
38
+ params[next_node.param_name.to_s] = segments[index..-1].join('/')
39
+ end
40
+ return [next_node, true]
41
+ end
42
+
43
+ # No match at this node
44
+ [nil, false]
45
+ end
46
+
15
47
  def add_handler(method, handler)
16
48
  @handlers[method.to_s] = handler
17
49
  @is_endpoint = true
@@ -1,3 +1,5 @@
1
+ require_relative 'segment'
2
+
1
3
  module RubyRoutes
2
4
  class RadixTree
3
5
  class << self
@@ -23,47 +25,30 @@ module RubyRoutes
23
25
  end
24
26
 
25
27
  def add(path, methods, handler)
26
- segments = split_path(path)
27
28
  current = @root
28
-
29
- segments.each do |segment|
30
- if segment.start_with?('*')
31
- current.wildcard_child ||= Node.new
32
- current = current.wildcard_child
33
- current.param_name = segment[1..-1] || 'splat'
34
- break
35
- elsif segment.start_with?(':')
36
- current.dynamic_child ||= Node.new
37
- current = current.dynamic_child
38
- current.param_name = segment[1..-1]
39
- else
40
- current.static_children[segment] ||= Node.new
41
- current = current.static_children[segment]
42
- end
29
+ parse_segments(path).each do |seg|
30
+ current = seg.ensure_child(current)
31
+ break if seg.wildcard?
43
32
  end
44
33
 
45
34
  methods.each { |method| current.add_handler(method, handler) }
46
35
  end
47
36
 
48
- def find(path, method)
37
+ def parse_segments(path)
38
+ split_path(path).map { |s| RubyRoutes::Segment.for(s) }
39
+ end
40
+
41
+ def find(path, method, params_out = nil)
49
42
  segments = split_path(path)
50
43
  current = @root
51
- params = {}
52
-
53
- segments.each_with_index do |segment, index|
54
- if current.static_children.key?(segment)
55
- current = current.static_children[segment]
56
- elsif current.dynamic_child
57
- current = current.dynamic_child
58
- # keep string keys to avoid symbol allocations and extra conversions later
59
- params[current.param_name.to_s] = segment
60
- elsif current.wildcard_child
61
- current = current.wildcard_child
62
- params[current.param_name.to_s] = segments[index..-1].join('/')
63
- break
64
- else
65
- return [nil, {}]
66
- end
44
+ params = params_out || {}
45
+ params.clear if params_out
46
+
47
+ segments.each_with_index do |text, idx|
48
+ next_node, should_break = current.traverse_for(text, idx, segments, params)
49
+ return [nil, {}] unless next_node
50
+ current = next_node
51
+ break if should_break
67
52
  end
68
53
 
69
54
  handler = current.get_handler(method)
@@ -0,0 +1,43 @@
1
+ module RubyRoutes
2
+ class Route
3
+ # small LRU used for path generation cache
4
+ class SmallLru
5
+ attr_reader :hits, :misses, :evictions
6
+
7
+ # larger default to reduce eviction likelihood in benchmarks
8
+ def initialize(max_size = 1024)
9
+ @max_size = max_size
10
+ @h = {}
11
+ @hits = 0
12
+ @misses = 0
13
+ @evictions = 0
14
+
15
+ @hit_strategy = RubyRoutes::Constant::LRU_HIT_STRATEGY
16
+ @miss_strategy = RubyRoutes::Constant::LRU_MISS_STRATEGY
17
+ end
18
+
19
+ def get(key)
20
+ strategy = @h.key?(key) ? @hit_strategy : @miss_strategy
21
+ strategy.call(self, key)
22
+ end
23
+
24
+ def set(key, val)
25
+ @h.delete(key) if @h.key?(key)
26
+ @h[key] = val
27
+ if @h.size > @max_size
28
+ @h.shift
29
+ @evictions += 1
30
+ end
31
+ val
32
+ end
33
+
34
+ def increment_hits
35
+ @hits += 1
36
+ end
37
+
38
+ def increment_misses
39
+ @misses += 1
40
+ end
41
+ end
42
+ end
43
+ end
@@ -1,43 +1,8 @@
1
1
  require 'uri'
2
+ require_relative 'route/small_lru'
2
3
 
3
4
  module RubyRoutes
4
5
  class Route
5
- # small LRU used for path generation cache
6
- class SmallLru
7
- attr_reader :hits, :misses, :evictions
8
-
9
- # larger default to reduce eviction likelihood in benchmarks
10
- def initialize(max_size = 1024)
11
- @max_size = max_size
12
- @h = {}
13
- @hits = 0
14
- @misses = 0
15
- @evictions = 0
16
- end
17
-
18
- def get(key)
19
- if @h.key?(key)
20
- @hits += 1
21
- val = @h.delete(key)
22
- @h[key] = val
23
- val
24
- else
25
- @misses += 1
26
- nil
27
- end
28
- end
29
-
30
- def set(key, val)
31
- @h.delete(key) if @h.key?(key)
32
- @h[key] = val
33
- if @h.size > @max_size
34
- @h.shift
35
- @evictions += 1
36
- end
37
- val
38
- end
39
- end
40
-
41
6
  attr_reader :path, :methods, :controller, :action, :name, :constraints, :defaults
42
7
 
43
8
  def initialize(path, options = {})
@@ -58,17 +23,29 @@ module RubyRoutes
58
23
  !!extract_path_params(request_path)
59
24
  end
60
25
 
61
- def extract_params(request_path)
26
+ def extract_params(request_path, parsed_qp = nil)
62
27
  path_params = extract_path_params(request_path)
63
28
  return {} unless path_params
64
29
 
65
- params = path_params.dup
66
- params.merge!(query_params(request_path)) # query_params returns string-keyed hash below
67
- # defaults already string keys, only merge keys that aren't present
68
- defaults.each { |k, v| params[k] = v unless params.key?(k) }
30
+ # Reuse a thread-local hash to reduce allocations; return a dup to callers.
31
+ tmp = Thread.current[:ruby_routes_params] ||= {}
32
+ tmp.clear
69
33
 
70
- validate_constraints!(params)
71
- params
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
72
49
  end
73
50
 
74
51
  def named?
@@ -83,18 +60,21 @@ module RubyRoutes
83
60
  !resource?
84
61
  end
85
62
 
63
+ def parse_query_params(path)
64
+ query_params(path)
65
+ end
66
+
86
67
  # Fast path generator: uses precompiled token list and a small LRU.
87
68
  # Avoids unbounded cache growth and skips URI-encoding for safe values.
88
69
  def generate_path(params = {})
89
70
  return '/' if path == '/'
90
71
 
91
- # build merged for only relevant param names
92
- merged = {}
93
- defaults.each { |k, v| merged[k] = v } # defaults already string keys
94
- params.each do |k, v|
95
- ks = k.to_s
96
- merged[ks] = v
97
- end
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
98
78
 
99
79
  missing = compiled_required_params - merged.keys
100
80
  raise RubyRoutes::RouteNotFound, "Missing params: #{missing.join(', ')}" unless missing.empty?
@@ -135,13 +115,7 @@ module RubyRoutes
135
115
  []
136
116
  else
137
117
  path.split('/').reject(&:empty?).map do |seg|
138
- if seg.start_with?(':')
139
- { type: :param, name: seg[1..-1] }
140
- elsif seg.start_with?('*')
141
- { type: :splat, name: (seg[1..-1] || 'splat') }
142
- else
143
- { type: :static, value: seg }
144
- end
118
+ RubyRoutes::Constant.segment_descriptor(seg)
145
119
  end
146
120
  end
147
121
  end
@@ -157,7 +131,21 @@ module RubyRoutes
157
131
  def cache_key_for(merged)
158
132
  # build key in route token order (parameters & splat) to avoid sorting/inspect
159
133
  names = compiled_param_names
160
- names.map { |n| merged[n].to_s }.join('|')
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
161
149
  end
162
150
 
163
151
  def compiled_param_names
@@ -223,7 +211,7 @@ module RubyRoutes
223
211
  qidx = path.index('?')
224
212
  return {} unless qidx
225
213
  qs = path[(qidx + 1)..-1] || ''
226
- Rack::Utils.parse_query(qs).transform_keys(&:to_s)
214
+ Rack::Utils.parse_query(qs)
227
215
  end
228
216
 
229
217
  def validate_constraints!(params)
@@ -5,7 +5,10 @@ module RubyRoutes
5
5
  def initialize
6
6
  @tree = RubyRoutes::RadixTree.new
7
7
  @named_routes = {}
8
- @routes = [] # keep list for specs / iteration / size
8
+ @routes = [] # keep list for specs / iteration / size
9
+ @recognition_cache = {} # simple bounded cache: key -> [route, params]
10
+ @recognition_cache_order = []
11
+ @recognition_cache_max = 4096
9
12
  end
10
13
 
11
14
  def add_route(route)
@@ -26,19 +29,40 @@ module RubyRoutes
26
29
  end
27
30
 
28
31
  def match(request_method, request_path)
29
- # Use RadixTree lookup and the path params it returns (avoid reparsing the path)
30
- handler, path_params = @tree.find(request_path, request_method.to_s.upcase)
32
+ # Normalize method once and attempt recognition cache hit
33
+ method_up = request_method.to_s.upcase
34
+ cache_key = "#{method_up}:#{request_path}"
35
+ if (cached = @recognition_cache[cache_key])
36
+ # Return cached params (frozen) directly to avoid heavy dup allocations.
37
+ cached_route, cached_params = cached
38
+ return { route: cached_route, params: cached_params, controller: cached_route.controller, action: cached_route.action }
39
+ end
40
+
41
+ # Use a thread-local hash as output for RadixTree to avoid allocating a params Hash
42
+ tmp = Thread.current[:ruby_routes_params] ||= {}
43
+ handler, _ = @tree.find(request_path, method_up, tmp)
31
44
  return nil unless handler
32
-
33
45
  route = handler
34
46
 
35
- # path_params are already string-keyed in RadixTree; merge defaults + query params
36
- params = (path_params || {}).dup
37
- params = route.defaults.transform_keys(&:to_s).merge(params)
38
- params.merge!(route.send(:query_params, request_path))
39
-
40
- # Note: lightweight constraint checks are performed during RadixTree#find.
41
- # Skip full constraint re-validation here to avoid double work.
47
+ # tmp now contains path params (filled by RadixTree). Merge defaults and query params in-place.
48
+ # defaults first (only set missing keys)
49
+ if route.defaults
50
+ route.defaults.each { |k, v| tmp[k] = v unless tmp.key?(k) }
51
+ end
52
+ if request_path.include?('?')
53
+ qp = route.parse_query_params(request_path)
54
+ qp.each { |k, v| tmp[k] = v } unless qp.empty?
55
+ end
56
+
57
+ params = tmp.dup
58
+
59
+ # insert into bounded recognition cache (store frozen params to reduce accidental mutation)
60
+ @recognition_cache[cache_key] = [route, params.freeze]
61
+ @recognition_cache_order << cache_key
62
+ if @recognition_cache_order.size > @recognition_cache_max
63
+ oldest = @recognition_cache_order.shift
64
+ @recognition_cache.delete(oldest)
65
+ end
42
66
 
43
67
  {
44
68
  route: route,
@@ -58,8 +82,8 @@ module RubyRoutes
58
82
  end
59
83
 
60
84
  def generate_path_from_route(route, params = {})
61
- # Delegate to Route#generate_path which uses precompiled segments + cache
62
- route.generate_path(params)
85
+ # Delegate to Route#generate_path which uses precompiled segments + cache
86
+ route.generate_path(params)
63
87
  end
64
88
 
65
89
  def clear!
@@ -0,0 +1,20 @@
1
+ require_relative 'segments/base_segment'
2
+ require_relative 'segments/dynamic_segment'
3
+ require_relative 'segments/static_segment'
4
+ require_relative 'segments/wildcard_segment'
5
+ require_relative 'constant'
6
+
7
+ module RubyRoutes
8
+ class Segment
9
+ def self.for(text)
10
+ t = text.to_s
11
+ key = t.empty? ? :default : t.getbyte(0)
12
+ segment = RubyRoutes::Constant::SEGMENTS[key] || RubyRoutes::Constant::SEGMENTS[:default]
13
+ segment.new(t)
14
+ end
15
+
16
+ def wildcard?
17
+ false
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,21 @@
1
+ module RubyRoutes
2
+ module Segments
3
+ class BaseSegment
4
+ def initialize(text = nil)
5
+ @text = text.to_s if text
6
+ end
7
+
8
+ def wildcard?
9
+ false
10
+ end
11
+
12
+ def ensure_child(current)
13
+ raise NotImplementedError, "#{self.class}#ensure_child must be implemented"
14
+ end
15
+
16
+ def match(_node, _text, _idx, _segments, _params)
17
+ raise NotImplementedError, "#{self.class}#match must be implemented"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ module RubyRoutes
2
+ module Segments
3
+ class DynamicSegment < BaseSegment
4
+ def initialize(text)
5
+ @name = text[1..-1]
6
+ end
7
+
8
+ def ensure_child(current)
9
+ current.dynamic_child ||= Node.new
10
+ current = current.dynamic_child
11
+ current.param_name = @name
12
+ current
13
+ end
14
+
15
+ def match(node, text, _idx, _segments, params)
16
+ return [nil, false] unless node.dynamic_child
17
+ nxt = node.dynamic_child
18
+ params[nxt.param_name.to_s] = text if params
19
+ [nxt, false]
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ module RubyRoutes
2
+ module Segments
3
+ class StaticSegment < BaseSegment
4
+ def initialize(text)
5
+ @text = text
6
+ end
7
+
8
+ def ensure_child(current)
9
+ current.static_children[@text] ||= Node.new
10
+ current.static_children[@text]
11
+ end
12
+
13
+ def match(node, text, _idx, _segments, _params)
14
+ [node.static_children[text], false]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,27 @@
1
+ module RubyRoutes
2
+ module Segments
3
+ class WildcardSegment < BaseSegment
4
+ def initialize(text)
5
+ @name = (text[1..-1] || 'splat')
6
+ end
7
+
8
+ def ensure_child(current)
9
+ current.wildcard_child ||= Node.new
10
+ current = current.wildcard_child
11
+ current.param_name = @name
12
+ current
13
+ end
14
+
15
+ def wildcard?
16
+ true
17
+ end
18
+
19
+ def match(node, _text, idx, segments, params)
20
+ return [nil, false] unless node.wildcard_child
21
+ nxt = node.wildcard_child
22
+ params[nxt.param_name.to_s] = segments[idx..-1].join('/') if params
23
+ [nxt, true]
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,3 +1,3 @@
1
1
  module RubyRoutes
2
- VERSION = "0.2.0"
2
+ VERSION = "1.0.0"
3
3
  end
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: 0.2.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yosef Benny Widyokarsono
@@ -62,11 +62,20 @@ files:
62
62
  - LICENSE
63
63
  - README.md
64
64
  - lib/ruby_routes.rb
65
+ - lib/ruby_routes/constant.rb
66
+ - lib/ruby_routes/lru_strategies/hit_strategy.rb
67
+ - lib/ruby_routes/lru_strategies/miss_strategy.rb
65
68
  - lib/ruby_routes/node.rb
66
69
  - lib/ruby_routes/radix_tree.rb
67
70
  - lib/ruby_routes/route.rb
71
+ - lib/ruby_routes/route/small_lru.rb
68
72
  - lib/ruby_routes/route_set.rb
69
73
  - lib/ruby_routes/router.rb
74
+ - lib/ruby_routes/segment.rb
75
+ - lib/ruby_routes/segments/base_segment.rb
76
+ - lib/ruby_routes/segments/dynamic_segment.rb
77
+ - lib/ruby_routes/segments/static_segment.rb
78
+ - lib/ruby_routes/segments/wildcard_segment.rb
70
79
  - lib/ruby_routes/string_extensions.rb
71
80
  - lib/ruby_routes/url_helpers.rb
72
81
  - lib/ruby_routes/version.rb