tarsier 0.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 +7 -0
- data/CHANGELOG.md +175 -0
- data/LICENSE.txt +21 -0
- data/README.md +984 -0
- data/exe/tarsier +7 -0
- data/lib/tarsier/application.rb +336 -0
- data/lib/tarsier/cli/commands/console.rb +87 -0
- data/lib/tarsier/cli/commands/generate.rb +85 -0
- data/lib/tarsier/cli/commands/help.rb +50 -0
- data/lib/tarsier/cli/commands/new.rb +59 -0
- data/lib/tarsier/cli/commands/routes.rb +139 -0
- data/lib/tarsier/cli/commands/server.rb +123 -0
- data/lib/tarsier/cli/commands/version.rb +14 -0
- data/lib/tarsier/cli/generators/app.rb +528 -0
- data/lib/tarsier/cli/generators/base.rb +93 -0
- data/lib/tarsier/cli/generators/controller.rb +91 -0
- data/lib/tarsier/cli/generators/middleware.rb +81 -0
- data/lib/tarsier/cli/generators/migration.rb +109 -0
- data/lib/tarsier/cli/generators/model.rb +109 -0
- data/lib/tarsier/cli/generators/resource.rb +27 -0
- data/lib/tarsier/cli/loader.rb +18 -0
- data/lib/tarsier/cli.rb +46 -0
- data/lib/tarsier/controller.rb +282 -0
- data/lib/tarsier/database.rb +588 -0
- data/lib/tarsier/errors.rb +77 -0
- data/lib/tarsier/middleware/base.rb +47 -0
- data/lib/tarsier/middleware/compression.rb +113 -0
- data/lib/tarsier/middleware/cors.rb +101 -0
- data/lib/tarsier/middleware/csrf.rb +88 -0
- data/lib/tarsier/middleware/logger.rb +74 -0
- data/lib/tarsier/middleware/rate_limit.rb +110 -0
- data/lib/tarsier/middleware/stack.rb +143 -0
- data/lib/tarsier/middleware/static.rb +124 -0
- data/lib/tarsier/model.rb +590 -0
- data/lib/tarsier/params.rb +269 -0
- data/lib/tarsier/query.rb +495 -0
- data/lib/tarsier/request.rb +274 -0
- data/lib/tarsier/response.rb +282 -0
- data/lib/tarsier/router/compiler.rb +173 -0
- data/lib/tarsier/router/node.rb +97 -0
- data/lib/tarsier/router/route.rb +119 -0
- data/lib/tarsier/router.rb +272 -0
- data/lib/tarsier/version.rb +5 -0
- data/lib/tarsier/websocket.rb +275 -0
- data/lib/tarsier.rb +167 -0
- data/sig/tarsier.rbs +485 -0
- metadata +230 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarsier
|
|
4
|
+
class Router
|
|
5
|
+
# Compiles routes into an optimized radix tree structure
|
|
6
|
+
# Routes are compiled at boot time for zero runtime overhead
|
|
7
|
+
class Compiler
|
|
8
|
+
attr_reader :trees
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
# Separate tree per HTTP method for faster lookups
|
|
12
|
+
@trees = {}
|
|
13
|
+
@named_routes = {}
|
|
14
|
+
@compiled = false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Add a route to the compiler
|
|
18
|
+
# @param route [Route] the route to add
|
|
19
|
+
def add(route)
|
|
20
|
+
raise InvalidRouteError, "Cannot add routes after compilation" if @compiled
|
|
21
|
+
|
|
22
|
+
tree = (@trees[route.method] ||= Node.new)
|
|
23
|
+
insert_route(tree, route, route.segments)
|
|
24
|
+
|
|
25
|
+
@named_routes[route.name] = route if route.name
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Compile all routes (call after all routes are added)
|
|
29
|
+
# Optimizes the tree structure for matching
|
|
30
|
+
def compile!
|
|
31
|
+
return if @compiled
|
|
32
|
+
|
|
33
|
+
@trees.each_value { |tree| optimize_tree(tree) }
|
|
34
|
+
@compiled = true
|
|
35
|
+
freeze_trees
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check if routes have been compiled
|
|
39
|
+
# @return [Boolean]
|
|
40
|
+
def compiled?
|
|
41
|
+
@compiled
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Match a path against the compiled routes
|
|
45
|
+
# @param method [Symbol] HTTP method
|
|
46
|
+
# @param path [String] the path to match
|
|
47
|
+
# @return [Array<Route, Hash>, nil] route and params or nil
|
|
48
|
+
def match(method, path)
|
|
49
|
+
tree = @trees[method.to_s.upcase.to_sym]
|
|
50
|
+
return nil unless tree
|
|
51
|
+
|
|
52
|
+
segments = path == "/" ? [""] : path.split("/").drop(1)
|
|
53
|
+
match_segments(tree, segments, {})
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get a named route
|
|
57
|
+
# @param name [Symbol] route name
|
|
58
|
+
# @return [Route, nil]
|
|
59
|
+
def named_route(name)
|
|
60
|
+
@named_routes[name]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Generate a path for a named route
|
|
64
|
+
# @param name [Symbol] route name
|
|
65
|
+
# @param params [Hash] parameters
|
|
66
|
+
# @return [String]
|
|
67
|
+
def path_for(name, params = {})
|
|
68
|
+
route = @named_routes[name]
|
|
69
|
+
raise InvalidRouteError, "Unknown route: #{name}" unless route
|
|
70
|
+
route.generate(params)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Get all routes
|
|
74
|
+
# @return [Array<Route>]
|
|
75
|
+
def routes
|
|
76
|
+
@trees.flat_map { |_method, tree| collect_routes(tree) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def insert_route(node, route, segments, index = 0)
|
|
82
|
+
if index >= segments.length
|
|
83
|
+
raise InvalidRouteError, "Duplicate route: #{route.method} #{route.path}" if node.route
|
|
84
|
+
node.route = route
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
segment = segments[index]
|
|
89
|
+
child = find_or_create_child(node, segment)
|
|
90
|
+
insert_route(child, route, segments, index + 1)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def find_or_create_child(node, segment)
|
|
94
|
+
key = segment_key(segment)
|
|
95
|
+
existing = node.find_child(key)
|
|
96
|
+
return existing if existing
|
|
97
|
+
|
|
98
|
+
child = create_node(segment)
|
|
99
|
+
node.add_child(key, child)
|
|
100
|
+
node.invalidate_cache!
|
|
101
|
+
child
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def segment_key(segment)
|
|
105
|
+
case segment[:type]
|
|
106
|
+
when :static then segment[:value]
|
|
107
|
+
when :param then ":#{segment[:name]}"
|
|
108
|
+
when :wildcard then "*#{segment[:name]}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def create_node(segment)
|
|
113
|
+
case segment[:type]
|
|
114
|
+
when :static
|
|
115
|
+
Node.new(segment[:value], type: Node::STATIC)
|
|
116
|
+
when :param
|
|
117
|
+
Node.new("", type: Node::PARAM, param_name: segment[:name], constraint: segment[:constraint])
|
|
118
|
+
when :wildcard
|
|
119
|
+
Node.new("", type: Node::WILDCARD, param_name: segment[:name])
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def match_segments(node, segments, params, index = 0)
|
|
124
|
+
# Terminal case: all segments consumed
|
|
125
|
+
if index >= segments.length
|
|
126
|
+
return [node.route, params] if node.terminal?
|
|
127
|
+
return nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
segment = segments[index]
|
|
131
|
+
|
|
132
|
+
# Try children in priority order (static > param > wildcard)
|
|
133
|
+
node.sorted_children.each do |child|
|
|
134
|
+
case child.type
|
|
135
|
+
when Node::STATIC
|
|
136
|
+
next unless child.segment == segment
|
|
137
|
+
result = match_segments(child, segments, params, index + 1)
|
|
138
|
+
return result if result
|
|
139
|
+
when Node::PARAM
|
|
140
|
+
captured = child.match?(segment)
|
|
141
|
+
next unless captured
|
|
142
|
+
result = match_segments(child, segments, params.merge(captured), index + 1)
|
|
143
|
+
return result if result
|
|
144
|
+
when Node::WILDCARD
|
|
145
|
+
# Wildcard captures rest of path
|
|
146
|
+
remaining = segments[index..].join("/")
|
|
147
|
+
new_params = params.merge(child.param_name => remaining)
|
|
148
|
+
return [child.route, new_params] if child.terminal?
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def optimize_tree(node)
|
|
156
|
+
# Sort children by priority and cache
|
|
157
|
+
node.sorted_children
|
|
158
|
+
node.children.each_value { |child| optimize_tree(child) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def freeze_trees
|
|
162
|
+
@trees.freeze
|
|
163
|
+
@named_routes.freeze
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def collect_routes(node, routes = [])
|
|
167
|
+
routes << node.route if node.route
|
|
168
|
+
node.children.each_value { |child| collect_routes(child, routes) }
|
|
169
|
+
routes
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarsier
|
|
4
|
+
class Router
|
|
5
|
+
# Radix tree node for efficient route matching
|
|
6
|
+
# Supports static segments, parameters, wildcards, and constraints
|
|
7
|
+
class Node
|
|
8
|
+
# Node types for optimized matching
|
|
9
|
+
STATIC = :static
|
|
10
|
+
PARAM = :param
|
|
11
|
+
WILDCARD = :wildcard
|
|
12
|
+
|
|
13
|
+
attr_reader :segment, :type, :param_name, :constraint, :children
|
|
14
|
+
attr_accessor :route, :priority
|
|
15
|
+
|
|
16
|
+
# @param segment [String] the path segment this node represents
|
|
17
|
+
# @param type [Symbol] node type (:static, :param, :wildcard)
|
|
18
|
+
# @param param_name [String, nil] parameter name for dynamic segments
|
|
19
|
+
# @param constraint [Regexp, nil] constraint pattern for parameters
|
|
20
|
+
def initialize(segment = "", type: STATIC, param_name: nil, constraint: nil)
|
|
21
|
+
@segment = segment.freeze
|
|
22
|
+
@type = type
|
|
23
|
+
@param_name = param_name&.to_sym
|
|
24
|
+
@constraint = constraint
|
|
25
|
+
@children = {}
|
|
26
|
+
@route = nil
|
|
27
|
+
@priority = calculate_priority
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Add a child node
|
|
31
|
+
# @param key [String] the key for the child (segment or param marker)
|
|
32
|
+
# @param node [Node] the child node
|
|
33
|
+
# @return [Node] the added child node
|
|
34
|
+
def add_child(key, node)
|
|
35
|
+
@children[key] = node
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Find a child node by key
|
|
39
|
+
# @param key [String] the key to search for
|
|
40
|
+
# @return [Node, nil]
|
|
41
|
+
def find_child(key)
|
|
42
|
+
@children[key]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check if this node matches a segment
|
|
46
|
+
# @param segment [String] the segment to match
|
|
47
|
+
# @return [Boolean, Hash] false or hash with captured params
|
|
48
|
+
def match?(segment)
|
|
49
|
+
case @type
|
|
50
|
+
when STATIC
|
|
51
|
+
segment == @segment ? {} : false
|
|
52
|
+
when PARAM
|
|
53
|
+
match_param(segment)
|
|
54
|
+
when WILDCARD
|
|
55
|
+
{ @param_name => segment }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if this is a terminal node (has a route)
|
|
60
|
+
# @return [Boolean]
|
|
61
|
+
def terminal?
|
|
62
|
+
!@route.nil?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Get sorted children for matching (static first, then params, then wildcards)
|
|
66
|
+
# @return [Array<Node>]
|
|
67
|
+
def sorted_children
|
|
68
|
+
@sorted_children ||= @children.values.sort_by(&:priority)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Clear cached sorted children (call after adding children)
|
|
72
|
+
def invalidate_cache!
|
|
73
|
+
@sorted_children = nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def match_param(segment)
|
|
79
|
+
return false if segment.nil? || segment.empty?
|
|
80
|
+
|
|
81
|
+
if @constraint
|
|
82
|
+
return false unless @constraint.match?(segment)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
{ @param_name => segment }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def calculate_priority
|
|
89
|
+
case @type
|
|
90
|
+
when STATIC then 0
|
|
91
|
+
when PARAM then 1
|
|
92
|
+
when WILDCARD then 2
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarsier
|
|
4
|
+
class Router
|
|
5
|
+
# Represents a compiled route with handler and metadata
|
|
6
|
+
class Route
|
|
7
|
+
attr_reader :method, :path, :handler, :name, :constraints, :middleware, :options
|
|
8
|
+
|
|
9
|
+
# @param method [Symbol] HTTP method (:get, :post, etc.)
|
|
10
|
+
# @param path [String] the route path pattern
|
|
11
|
+
# @param handler [Object] the route handler (controller.action or proc)
|
|
12
|
+
# @param name [Symbol, nil] optional route name for URL generation
|
|
13
|
+
# @param constraints [Hash] parameter constraints
|
|
14
|
+
# @param middleware [Array] route-specific middleware
|
|
15
|
+
# @param options [Hash] additional route options
|
|
16
|
+
def initialize(method:, path:, handler:, name: nil, constraints: {}, middleware: [], **options)
|
|
17
|
+
@method = method.to_s.upcase.to_sym
|
|
18
|
+
@path = normalize_path(path)
|
|
19
|
+
@handler = handler
|
|
20
|
+
@name = name
|
|
21
|
+
@constraints = constraints
|
|
22
|
+
@middleware = middleware
|
|
23
|
+
@options = options
|
|
24
|
+
@segments = nil
|
|
25
|
+
@param_names = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Parse path into segments for tree insertion
|
|
29
|
+
# @return [Array<Hash>] array of segment info hashes
|
|
30
|
+
def segments
|
|
31
|
+
@segments ||= parse_segments
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Get parameter names from the path
|
|
35
|
+
# @return [Array<Symbol>]
|
|
36
|
+
def param_names
|
|
37
|
+
@param_names ||= segments.select { |s| s[:type] != :static }.map { |s| s[:name] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Check if route has parameters
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
def dynamic?
|
|
43
|
+
segments.any? { |s| s[:type] != :static }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Generate a path with given parameters
|
|
47
|
+
# @param params [Hash] parameters to interpolate
|
|
48
|
+
# @return [String] the generated path
|
|
49
|
+
def generate(params = {})
|
|
50
|
+
segments.map do |seg|
|
|
51
|
+
case seg[:type]
|
|
52
|
+
when :static
|
|
53
|
+
seg[:value]
|
|
54
|
+
when :param, :wildcard
|
|
55
|
+
value = params[seg[:name]] || params[seg[:name].to_s]
|
|
56
|
+
raise ArgumentError, "Missing required parameter: #{seg[:name]}" unless value
|
|
57
|
+
value.to_s
|
|
58
|
+
end
|
|
59
|
+
end.join("/").then { |p| "/#{p}" }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Get the controller class if handler is controller-based
|
|
63
|
+
# @return [Class, nil]
|
|
64
|
+
def controller_class
|
|
65
|
+
return nil unless @handler.is_a?(Hash)
|
|
66
|
+
@handler[:controller]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Get the action name if handler is controller-based
|
|
70
|
+
# @return [Symbol, nil]
|
|
71
|
+
def action_name
|
|
72
|
+
return nil unless @handler.is_a?(Hash)
|
|
73
|
+
@handler[:action]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Check if handler is a proc/lambda
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
def proc_handler?
|
|
79
|
+
@handler.is_a?(Proc)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def normalize_path(path)
|
|
85
|
+
path = "/#{path}" unless path.start_with?("/")
|
|
86
|
+
path = path.chomp("/") unless path == "/"
|
|
87
|
+
path
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parse_segments
|
|
91
|
+
return [{ type: :static, value: "" }] if @path == "/"
|
|
92
|
+
|
|
93
|
+
@path.split("/").reject(&:empty?).map do |segment|
|
|
94
|
+
parse_segment(segment)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def parse_segment(segment)
|
|
99
|
+
case segment
|
|
100
|
+
when /\A:(\w+)(?:\{(.+)\})?\z/
|
|
101
|
+
# Parameter with optional constraint: :id or :id{\d+}
|
|
102
|
+
name = ::Regexp.last_match(1).to_sym
|
|
103
|
+
constraint = ::Regexp.last_match(2)
|
|
104
|
+
{
|
|
105
|
+
type: :param,
|
|
106
|
+
name: name,
|
|
107
|
+
constraint: constraint ? Regexp.new("\\A#{constraint}\\z") : @constraints[name]
|
|
108
|
+
}
|
|
109
|
+
when /\A\*(\w+)\z/
|
|
110
|
+
# Wildcard: *path
|
|
111
|
+
{ type: :wildcard, name: ::Regexp.last_match(1).to_sym }
|
|
112
|
+
else
|
|
113
|
+
# Static segment
|
|
114
|
+
{ type: :static, value: segment }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarsier
|
|
4
|
+
# High-performance router with compiled radix tree matching
|
|
5
|
+
# Supports RESTful resources, nested routes, and constraints
|
|
6
|
+
class Router
|
|
7
|
+
HTTP_METHODS = %i[get post put patch delete head options].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :compiler
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@compiler = Compiler.new
|
|
13
|
+
@scope_stack = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Define routes in a block
|
|
17
|
+
# @yield [self]
|
|
18
|
+
def draw(&block)
|
|
19
|
+
instance_eval(&block)
|
|
20
|
+
compile!
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Compile routes for optimized matching
|
|
25
|
+
def compile!
|
|
26
|
+
@compiler.compile!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Match a request to a route
|
|
30
|
+
# @param method [Symbol, String] HTTP method
|
|
31
|
+
# @param path [String] request path
|
|
32
|
+
# @return [Array<Route, Hash>, nil] matched route and params
|
|
33
|
+
def match(method, path)
|
|
34
|
+
@compiler.match(method, path)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Generate path for a named route
|
|
38
|
+
# @param name [Symbol] route name
|
|
39
|
+
# @param params [Hash] route parameters
|
|
40
|
+
# @return [String]
|
|
41
|
+
def path_for(name, params = {})
|
|
42
|
+
@compiler.path_for(name, params)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Get all defined routes
|
|
46
|
+
# @return [Array<Route>]
|
|
47
|
+
def routes
|
|
48
|
+
@compiler.routes
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# HTTP method helpers
|
|
52
|
+
HTTP_METHODS.each do |method|
|
|
53
|
+
define_method(method) do |path, to: nil, as: nil, constraints: {}, **options, &block|
|
|
54
|
+
add_route(method, path, to: to, as: as, constraints: constraints, **options, &block)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Define RESTful resources
|
|
59
|
+
# @param name [Symbol] resource name
|
|
60
|
+
# @param only [Array<Symbol>] actions to include
|
|
61
|
+
# @param except [Array<Symbol>] actions to exclude
|
|
62
|
+
# @param controller [Symbol] controller name override
|
|
63
|
+
def resources(name, only: nil, except: nil, controller: nil, **options, &block)
|
|
64
|
+
controller_name = controller || name
|
|
65
|
+
actions = determine_actions(only, except)
|
|
66
|
+
|
|
67
|
+
resource_routes(name, controller_name, actions, **options)
|
|
68
|
+
|
|
69
|
+
if block_given?
|
|
70
|
+
scope(path: "#{name}/:#{singular(name)}_id") do
|
|
71
|
+
instance_eval(&block)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Define a singular resource
|
|
77
|
+
# @param name [Symbol] resource name
|
|
78
|
+
def resource(name, only: nil, except: nil, controller: nil, **options, &block)
|
|
79
|
+
controller_name = controller || name.to_s.pluralize.to_sym
|
|
80
|
+
actions = determine_singular_actions(only, except)
|
|
81
|
+
|
|
82
|
+
singular_resource_routes(name, controller_name, actions, **options)
|
|
83
|
+
|
|
84
|
+
if block_given?
|
|
85
|
+
scope(path: name.to_s) do
|
|
86
|
+
instance_eval(&block)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Create a route scope
|
|
92
|
+
# @param path [String] path prefix
|
|
93
|
+
# @param module_name [Symbol] controller module
|
|
94
|
+
# @param as [Symbol] name prefix
|
|
95
|
+
def scope(path: nil, module: nil, as: nil, **options, &block)
|
|
96
|
+
@scope_stack.push({
|
|
97
|
+
path: path,
|
|
98
|
+
module: binding.local_variable_get(:module),
|
|
99
|
+
as: as,
|
|
100
|
+
**options
|
|
101
|
+
})
|
|
102
|
+
instance_eval(&block)
|
|
103
|
+
@scope_stack.pop
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Namespace routes under a module
|
|
107
|
+
# @param name [Symbol] namespace name
|
|
108
|
+
def namespace(name, **options, &block)
|
|
109
|
+
scope(path: name.to_s, module: name, as: name, **options, &block)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Mount a Rack application
|
|
113
|
+
# @param app [Object] Rack application
|
|
114
|
+
# @param at [String] mount path
|
|
115
|
+
def mount(app, at:)
|
|
116
|
+
path = "#{at.chomp('/')}/*path"
|
|
117
|
+
add_route(:get, path, to: app, mount: true)
|
|
118
|
+
add_route(:post, path, to: app, mount: true)
|
|
119
|
+
add_route(:put, path, to: app, mount: true)
|
|
120
|
+
add_route(:patch, path, to: app, mount: true)
|
|
121
|
+
add_route(:delete, path, to: app, mount: true)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Root route helper
|
|
125
|
+
# @param to [Object] handler
|
|
126
|
+
def root(to:, as: :root)
|
|
127
|
+
get("/", to: to, as: as)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def add_route(method, path, to:, as: nil, constraints: {}, middleware: [], **options, &block)
|
|
133
|
+
full_path = build_path(path)
|
|
134
|
+
handler = resolve_handler(to, &block)
|
|
135
|
+
route_name = build_name(as)
|
|
136
|
+
|
|
137
|
+
route = Route.new(
|
|
138
|
+
method: method,
|
|
139
|
+
path: full_path,
|
|
140
|
+
handler: handler,
|
|
141
|
+
name: route_name,
|
|
142
|
+
constraints: constraints,
|
|
143
|
+
middleware: current_middleware + middleware,
|
|
144
|
+
**options
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@compiler.add(route)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build_path(path)
|
|
151
|
+
parts = @scope_stack.map { |s| s[:path] }.compact
|
|
152
|
+
parts << path
|
|
153
|
+
"/" + parts.join("/").gsub(%r{/+}, "/").gsub(%r{^/|/$}, "")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def build_name(name)
|
|
157
|
+
return nil unless name
|
|
158
|
+
|
|
159
|
+
parts = @scope_stack.map { |s| s[:as] }.compact
|
|
160
|
+
parts << name
|
|
161
|
+
parts.join("_").to_sym
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def resolve_handler(to, &block)
|
|
165
|
+
return block if block_given?
|
|
166
|
+
return to if to.is_a?(Proc) || to.respond_to?(:call)
|
|
167
|
+
|
|
168
|
+
case to
|
|
169
|
+
when Hash
|
|
170
|
+
to
|
|
171
|
+
when String
|
|
172
|
+
# "controller#action" format
|
|
173
|
+
controller, action = to.split("#")
|
|
174
|
+
controller_class = resolve_controller(controller)
|
|
175
|
+
{ controller: controller_class, action: action.to_sym }
|
|
176
|
+
else
|
|
177
|
+
raise InvalidRouteError, "Invalid handler: #{to.inspect}"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def resolve_controller(name)
|
|
182
|
+
parts = @scope_stack.map { |s| s[:module] }.compact
|
|
183
|
+
parts << name
|
|
184
|
+
|
|
185
|
+
class_name = parts.map { |p| camelize(p.to_s) }.join("::") + "Controller"
|
|
186
|
+
|
|
187
|
+
# Return the class name as a string for lazy loading
|
|
188
|
+
class_name
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def current_middleware
|
|
192
|
+
@scope_stack.flat_map { |s| s[:middleware] || [] }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def determine_actions(only, except)
|
|
196
|
+
all = %i[index show new create edit update destroy]
|
|
197
|
+
actions = only ? (all & Array(only)) : all
|
|
198
|
+
except ? (actions - Array(except)) : actions
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def determine_singular_actions(only, except)
|
|
202
|
+
all = %i[show new create edit update destroy]
|
|
203
|
+
actions = only ? (all & Array(only)) : all
|
|
204
|
+
except ? (actions - Array(except)) : actions
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def resource_routes(name, controller, actions, **options)
|
|
208
|
+
path_name = name.to_s
|
|
209
|
+
singular_name = singular(name)
|
|
210
|
+
|
|
211
|
+
actions.each do |action|
|
|
212
|
+
case action
|
|
213
|
+
when :index
|
|
214
|
+
get(path_name, to: "#{controller}#index", as: name, **options)
|
|
215
|
+
when :show
|
|
216
|
+
get("#{path_name}/:id", to: "#{controller}#show", as: singular_name, **options)
|
|
217
|
+
when :new
|
|
218
|
+
get("#{path_name}/new", to: "#{controller}#new", as: :"new_#{singular_name}", **options)
|
|
219
|
+
when :create
|
|
220
|
+
post(path_name, to: "#{controller}#create", **options)
|
|
221
|
+
when :edit
|
|
222
|
+
get("#{path_name}/:id/edit", to: "#{controller}#edit", as: :"edit_#{singular_name}", **options)
|
|
223
|
+
when :update
|
|
224
|
+
put("#{path_name}/:id", to: "#{controller}#update", **options)
|
|
225
|
+
patch("#{path_name}/:id", to: "#{controller}#update", **options)
|
|
226
|
+
when :destroy
|
|
227
|
+
delete("#{path_name}/:id", to: "#{controller}#destroy", **options)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def singular_resource_routes(name, controller, actions, **options)
|
|
233
|
+
path_name = name.to_s
|
|
234
|
+
|
|
235
|
+
actions.each do |action|
|
|
236
|
+
case action
|
|
237
|
+
when :show
|
|
238
|
+
get(path_name, to: "#{controller}#show", as: name, **options)
|
|
239
|
+
when :new
|
|
240
|
+
get("#{path_name}/new", to: "#{controller}#new", as: :"new_#{name}", **options)
|
|
241
|
+
when :create
|
|
242
|
+
post(path_name, to: "#{controller}#create", **options)
|
|
243
|
+
when :edit
|
|
244
|
+
get("#{path_name}/edit", to: "#{controller}#edit", as: :"edit_#{name}", **options)
|
|
245
|
+
when :update
|
|
246
|
+
put(path_name, to: "#{controller}#update", **options)
|
|
247
|
+
patch(path_name, to: "#{controller}#update", **options)
|
|
248
|
+
when :destroy
|
|
249
|
+
delete(path_name, to: "#{controller}#destroy", **options)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def singular(name)
|
|
255
|
+
# Simple singularization - can be enhanced
|
|
256
|
+
word = name.to_s
|
|
257
|
+
if word.end_with?("ies")
|
|
258
|
+
word[0..-4] + "y"
|
|
259
|
+
elsif word.end_with?("es")
|
|
260
|
+
word[0..-3]
|
|
261
|
+
elsif word.end_with?("s")
|
|
262
|
+
word[0..-2]
|
|
263
|
+
else
|
|
264
|
+
word
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def camelize(string)
|
|
269
|
+
string.split("_").map(&:capitalize).join
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|