otto 1.4.0 → 1.5.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/.gitignore +1 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +3 -3
- data/docs/.gitignore +2 -0
- data/lib/otto/response_handlers.rb +141 -0
- data/lib/otto/route.rb +120 -54
- data/lib/otto/route_definition.rb +187 -0
- data/lib/otto/route_handlers.rb +383 -0
- data/lib/otto/security/authentication.rb +289 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +70 -3
- metadata +6 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a50c097fe32fba3c0a6d84be84f95f68fffe385675f3fc353681b24f11aefa63
|
4
|
+
data.tar.gz: 9ad3e5c757531c10b1fbd60d304a07aa5ce84915491acb6c36b61656b8b6f6b3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c1be28acdcec40db91e12c39926a5197326b0c46b5d269187f8cc3114cfbcae6ae025f1fa69c6c8820faa076c2067c3200838a15818291c729df40271cca3b00
|
7
|
+
data.tar.gz: bfa3af6f70fd650464b935b8b30c687b2393b9a687b68837ab21ffcb2ffb710acc1dea85942e043a8307a4b2d6360b4037846e710f2fc43e47a66100dddea807
|
data/.gitignore
CHANGED
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
otto (1.
|
4
|
+
otto (1.5.0)
|
5
5
|
ostruct
|
6
6
|
rack (~> 3.1, < 4.0)
|
7
7
|
rack-parser (~> 0.7)
|
@@ -111,7 +111,7 @@ GEM
|
|
111
111
|
stringio (3.1.7)
|
112
112
|
syntax_tree (6.3.0)
|
113
113
|
prettier_print (>= 1.2.0)
|
114
|
-
tryouts (3.
|
114
|
+
tryouts (3.3.1)
|
115
115
|
irb
|
116
116
|
minitest (~> 5.0)
|
117
117
|
pastel (~> 0.8)
|
@@ -142,7 +142,7 @@ DEPENDENCIES
|
|
142
142
|
ruby-lsp
|
143
143
|
stackprof
|
144
144
|
syntax_tree
|
145
|
-
tryouts
|
145
|
+
tryouts (~> 3.3.1)
|
146
146
|
|
147
147
|
BUNDLED WITH
|
148
148
|
2.6.9
|
data/docs/.gitignore
ADDED
@@ -0,0 +1,141 @@
|
|
1
|
+
# lib/otto/response_handlers.rb
|
2
|
+
|
3
|
+
class Otto
|
4
|
+
module ResponseHandlers
|
5
|
+
# Base response handler class
|
6
|
+
class BaseHandler
|
7
|
+
def self.handle(result, response, context = {})
|
8
|
+
raise NotImplementedError, "Subclasses must implement handle method"
|
9
|
+
end
|
10
|
+
|
11
|
+
protected
|
12
|
+
|
13
|
+
def self.ensure_status_set(response, default_status = 200)
|
14
|
+
response.status = default_status unless response.status && response.status != 0
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# Handler for JSON responses
|
19
|
+
class JSONHandler < BaseHandler
|
20
|
+
def self.handle(result, response, context = {})
|
21
|
+
response['Content-Type'] = 'application/json'
|
22
|
+
|
23
|
+
# Determine the data to serialize
|
24
|
+
data = if context[:logic_instance]&.respond_to?(:response_data)
|
25
|
+
context[:logic_instance].response_data
|
26
|
+
elsif result.is_a?(Hash)
|
27
|
+
result
|
28
|
+
elsif result.nil?
|
29
|
+
{ success: true }
|
30
|
+
else
|
31
|
+
{ success: true, data: result }
|
32
|
+
end
|
33
|
+
|
34
|
+
response.body = [JSON.generate(data)]
|
35
|
+
ensure_status_set(response, context[:status_code] || 200)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Handler for redirect responses
|
40
|
+
class RedirectHandler < BaseHandler
|
41
|
+
def self.handle(result, response, context = {})
|
42
|
+
# Determine redirect path
|
43
|
+
path = if context[:redirect_path]
|
44
|
+
context[:redirect_path]
|
45
|
+
elsif context[:logic_instance]&.respond_to?(:redirect_path)
|
46
|
+
context[:logic_instance].redirect_path
|
47
|
+
elsif result.is_a?(String)
|
48
|
+
result
|
49
|
+
else
|
50
|
+
'/'
|
51
|
+
end
|
52
|
+
|
53
|
+
response.redirect(path)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Handler for view/template responses
|
58
|
+
class ViewHandler < BaseHandler
|
59
|
+
def self.handle(result, response, context = {})
|
60
|
+
if context[:logic_instance]&.respond_to?(:view)
|
61
|
+
response.body = [context[:logic_instance].view.render]
|
62
|
+
response['Content-Type'] = 'text/html' unless response['Content-Type']
|
63
|
+
elsif result.respond_to?(:to_s)
|
64
|
+
response.body = [result.to_s]
|
65
|
+
response['Content-Type'] = 'text/html' unless response['Content-Type']
|
66
|
+
else
|
67
|
+
response.body = ['']
|
68
|
+
end
|
69
|
+
|
70
|
+
ensure_status_set(response, context[:status_code] || 200)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Default handler that preserves existing Otto behavior
|
75
|
+
class DefaultHandler < BaseHandler
|
76
|
+
def self.handle(result, response, context = {})
|
77
|
+
# Otto's default behavior - let the route handler manage the response
|
78
|
+
# This handler does nothing, preserving existing behavior
|
79
|
+
ensure_status_set(response, 200)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Auto-detection handler that chooses appropriate handler based on context
|
84
|
+
class AutoHandler < BaseHandler
|
85
|
+
def self.handle(result, response, context = {})
|
86
|
+
# Auto-detect based on result type and request context
|
87
|
+
handler_class = detect_handler_type(result, response, context)
|
88
|
+
handler_class.handle(result, response, context)
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def self.detect_handler_type(result, response, context)
|
94
|
+
# Check if response type was already set by the handler
|
95
|
+
content_type = response['Content-Type']
|
96
|
+
|
97
|
+
if content_type&.include?('application/json')
|
98
|
+
JSONHandler
|
99
|
+
elsif (context[:logic_instance]&.respond_to?(:redirect_path) && context[:logic_instance]&.redirect_path) ||
|
100
|
+
(result.is_a?(String) && result.match?(%r{^/}))
|
101
|
+
# Logic instance has redirect path or result is a string path
|
102
|
+
RedirectHandler
|
103
|
+
elsif result.is_a?(Hash)
|
104
|
+
JSONHandler
|
105
|
+
elsif context[:logic_instance]&.respond_to?(:view)
|
106
|
+
ViewHandler
|
107
|
+
else
|
108
|
+
DefaultHandler
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Factory for creating response handlers
|
114
|
+
class HandlerFactory
|
115
|
+
# Map of response type names to handler classes
|
116
|
+
HANDLER_MAP = {
|
117
|
+
'json' => JSONHandler,
|
118
|
+
'redirect' => RedirectHandler,
|
119
|
+
'view' => ViewHandler,
|
120
|
+
'auto' => AutoHandler,
|
121
|
+
'default' => DefaultHandler
|
122
|
+
}.freeze
|
123
|
+
|
124
|
+
def self.create_handler(response_type)
|
125
|
+
handler_class = HANDLER_MAP[response_type.to_s.downcase]
|
126
|
+
|
127
|
+
unless handler_class
|
128
|
+
Otto.logger.warn "Unknown response type: #{response_type}, falling back to default" if Otto.debug
|
129
|
+
handler_class = DefaultHandler
|
130
|
+
end
|
131
|
+
|
132
|
+
handler_class
|
133
|
+
end
|
134
|
+
|
135
|
+
def self.handle_response(result, response, response_type, context = {})
|
136
|
+
handler = create_handler(response_type)
|
137
|
+
handler.handle(result, response, context)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
data/lib/otto/route.rb
CHANGED
@@ -23,35 +23,70 @@ class Otto
|
|
23
23
|
module ClassMethods
|
24
24
|
attr_accessor :otto
|
25
25
|
end
|
26
|
-
|
26
|
+
# @return [Otto::RouteDefinition] The immutable route definition
|
27
|
+
attr_reader :route_definition
|
28
|
+
|
29
|
+
# @return [Class] The resolved class object
|
30
|
+
attr_reader :klass
|
31
|
+
|
27
32
|
attr_accessor :otto
|
28
33
|
|
29
34
|
# Initialize a new route with security validations
|
30
35
|
#
|
31
36
|
# @param verb [String] HTTP verb (GET, POST, PUT, DELETE, etc.)
|
32
37
|
# @param path [String] URL path pattern with optional parameters
|
33
|
-
# @param definition [String] Class and method definition
|
38
|
+
# @param definition [String] Class and method definition with optional key-value parameters
|
39
|
+
# Examples:
|
40
|
+
# "Class.method" (traditional)
|
41
|
+
# "Class#method" (traditional)
|
42
|
+
# "V2::Logic::AuthSession auth=authenticated response=redirect" (enhanced)
|
34
43
|
# @raise [ArgumentError] if definition format is invalid or class name is unsafe
|
35
44
|
def initialize(verb, path, definition)
|
36
|
-
@
|
37
|
-
|
38
|
-
|
39
|
-
@pattern,
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
45
|
+
@pattern, @keys = *compile(path)
|
46
|
+
|
47
|
+
# Create immutable route definition
|
48
|
+
@route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern: @pattern, keys: @keys)
|
49
|
+
|
50
|
+
# Resolve the class
|
51
|
+
@klass = safe_const_get(@route_definition.klass_name)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Delegate common methods to route_definition for backward compatibility
|
55
|
+
def verb
|
56
|
+
@route_definition.verb
|
57
|
+
end
|
58
|
+
|
59
|
+
def path
|
60
|
+
@route_definition.path
|
61
|
+
end
|
62
|
+
|
63
|
+
def definition
|
64
|
+
@route_definition.definition
|
65
|
+
end
|
66
|
+
|
67
|
+
def pattern
|
68
|
+
@route_definition.pattern
|
69
|
+
end
|
70
|
+
|
71
|
+
def keys
|
72
|
+
@route_definition.keys
|
73
|
+
end
|
74
|
+
|
75
|
+
def name
|
76
|
+
@route_definition.method_name
|
77
|
+
end
|
78
|
+
|
79
|
+
def kind
|
80
|
+
@route_definition.kind
|
81
|
+
end
|
82
|
+
|
83
|
+
def route_options
|
84
|
+
@route_definition.options
|
51
85
|
end
|
52
86
|
|
53
87
|
private
|
54
88
|
|
89
|
+
|
55
90
|
# Safely resolve a class name using Object.const_get with security validations
|
56
91
|
# This replaces the previous eval() usage to prevent code injection attacks.
|
57
92
|
#
|
@@ -120,6 +155,10 @@ class Otto
|
|
120
155
|
env['otto.security_config'] = otto.security_config
|
121
156
|
end
|
122
157
|
|
158
|
+
# NEW: Make route definition and options available to middleware and handlers
|
159
|
+
env['otto.route_definition'] = @route_definition
|
160
|
+
env['otto.route_options'] = @route_definition.options
|
161
|
+
|
123
162
|
# Process parameters through security layer
|
124
163
|
req.params.merge! extra_params
|
125
164
|
req.params.replace Otto::Static.indifferent_params(req.params)
|
@@ -142,53 +181,80 @@ class Otto
|
|
142
181
|
# Add validation helpers
|
143
182
|
res.extend Otto::Security::ValidationHelpers
|
144
183
|
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
klass.send(name, req, res)
|
184
|
+
# NEW: Use the pluggable route handler factory (Phase 4)
|
185
|
+
# This replaces the hardcoded execution pattern with a factory approach
|
186
|
+
if otto&.route_handler_factory
|
187
|
+
handler = otto.route_handler_factory.create_handler(@route_definition, otto)
|
188
|
+
return handler.call(env, extra_params)
|
151
189
|
else
|
152
|
-
|
190
|
+
# Fallback to legacy behavior for backward compatibility
|
191
|
+
inst = nil
|
192
|
+
result = case kind
|
193
|
+
when :instance
|
194
|
+
inst = klass.new req, res
|
195
|
+
inst.send(name)
|
196
|
+
when :class
|
197
|
+
klass.send(name, req, res)
|
198
|
+
else
|
199
|
+
raise "Unsupported kind for #{definition}: #{kind}"
|
200
|
+
end
|
201
|
+
|
202
|
+
# Handle response based on route options
|
203
|
+
response_type = @route_definition.response_type
|
204
|
+
if response_type != 'default'
|
205
|
+
context = {
|
206
|
+
logic_instance: (kind == :instance ? inst : nil),
|
207
|
+
status_code: nil,
|
208
|
+
redirect_path: nil
|
209
|
+
}
|
210
|
+
|
211
|
+
Otto::ResponseHandlers::HandlerFactory.handle_response(result, res, response_type, context)
|
212
|
+
end
|
213
|
+
|
214
|
+
res.body = [res.body] unless res.body.respond_to?(:each)
|
215
|
+
res.finish
|
153
216
|
end
|
154
|
-
res.body = [res.body] unless res.body.respond_to?(:each)
|
155
|
-
res.finish
|
156
217
|
end
|
157
218
|
|
158
219
|
private
|
159
220
|
|
160
|
-
# Brazenly borrowed from Sinatra::Base:
|
161
|
-
# https://github.com/sinatra/sinatra/blob/v1.2.6/lib/sinatra/base.rb#L1156
|
162
221
|
def compile(path)
|
163
222
|
keys = []
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
case match
|
169
|
-
when '*'
|
170
|
-
keys << 'splat'
|
171
|
-
'(.*?)'
|
172
|
-
when *special_chars
|
173
|
-
Regexp.escape(match)
|
174
|
-
else
|
175
|
-
keys << ::Regexp.last_match(2)[1..-1]
|
176
|
-
'([^/?#]+)'
|
177
|
-
end
|
178
|
-
end
|
179
|
-
# Wrap the regex in parens so the regex works properly.
|
180
|
-
# They can fail when there's an | for example (matching only the last one).
|
181
|
-
# Note: this means we also need to remove the first matched value.
|
182
|
-
[/\A(#{pattern})\z/, keys]
|
183
|
-
elsif path.respond_to?(:keys) && path.respond_to?(:match)
|
184
|
-
[path, path.keys]
|
185
|
-
elsif path.respond_to?(:names) && path.respond_to?(:match)
|
186
|
-
[path, path.names]
|
187
|
-
elsif path.respond_to? :match
|
188
|
-
[path, keys]
|
223
|
+
|
224
|
+
# Handle string paths first (most common case)
|
225
|
+
if path.respond_to?(:to_str)
|
226
|
+
compile_string_path(path, keys)
|
189
227
|
else
|
190
|
-
|
228
|
+
case path
|
229
|
+
in { keys: route_keys, match: _ }
|
230
|
+
[path, route_keys]
|
231
|
+
in { names: route_names, match: _ }
|
232
|
+
[path, route_names]
|
233
|
+
in { match: _ }
|
234
|
+
[path, keys]
|
235
|
+
else
|
236
|
+
raise TypeError, path
|
237
|
+
end
|
191
238
|
end
|
192
239
|
end
|
240
|
+
|
241
|
+
def compile_string_path(path, keys)
|
242
|
+
raise TypeError, path unless path.respond_to?(:to_str)
|
243
|
+
|
244
|
+
pattern = path.to_str.gsub(/((:\w+)|[.*+()$])/) do |match|
|
245
|
+
case match
|
246
|
+
when '*'
|
247
|
+
keys << 'splat'
|
248
|
+
'(.*?)'
|
249
|
+
when '.', '+', '(', ')', '$'
|
250
|
+
Regexp.escape(match)
|
251
|
+
else
|
252
|
+
keys << match[1..-1] # Remove the colon
|
253
|
+
'([^/?#]+)'
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
[/\A(#{pattern})\z/, keys]
|
258
|
+
end
|
193
259
|
end
|
194
260
|
end
|
@@ -0,0 +1,187 @@
|
|
1
|
+
# lib/otto/route_definition.rb
|
2
|
+
|
3
|
+
class Otto
|
4
|
+
# Immutable data class representing a complete route definition
|
5
|
+
# This encapsulates all aspects of a route: path, target, and options
|
6
|
+
class RouteDefinition
|
7
|
+
# @return [String] The HTTP verb (GET, POST, etc.)
|
8
|
+
attr_reader :verb
|
9
|
+
|
10
|
+
# @return [String] The URL path pattern
|
11
|
+
attr_reader :path
|
12
|
+
|
13
|
+
# @return [String] The original definition string
|
14
|
+
attr_reader :definition
|
15
|
+
|
16
|
+
# @return [String] The target class and method (e.g., "TestApp.index")
|
17
|
+
attr_reader :target
|
18
|
+
|
19
|
+
# @return [String] The class name portion
|
20
|
+
attr_reader :klass_name
|
21
|
+
|
22
|
+
# @return [String] The method name portion
|
23
|
+
attr_reader :method_name
|
24
|
+
|
25
|
+
# @return [Symbol] The invocation kind (:class, :instance, or :logic)
|
26
|
+
attr_reader :kind
|
27
|
+
|
28
|
+
# @return [Hash] The route options (auth, response, csrf, etc.)
|
29
|
+
attr_reader :options
|
30
|
+
|
31
|
+
# @return [Regexp] The compiled path pattern for matching
|
32
|
+
attr_reader :pattern
|
33
|
+
|
34
|
+
# @return [Array<String>] The parameter keys extracted from the path
|
35
|
+
attr_reader :keys
|
36
|
+
|
37
|
+
def initialize(verb, path, definition, pattern: nil, keys: nil)
|
38
|
+
@verb = verb.to_s.upcase.to_sym
|
39
|
+
@path = path
|
40
|
+
@definition = definition
|
41
|
+
@pattern = pattern
|
42
|
+
@keys = keys || []
|
43
|
+
|
44
|
+
# Parse the definition into target and options
|
45
|
+
parsed = parse_definition(definition)
|
46
|
+
@target = parsed[:target]
|
47
|
+
@options = parsed[:options].freeze
|
48
|
+
|
49
|
+
# Parse the target into class, method, and kind
|
50
|
+
target_parsed = parse_target(@target)
|
51
|
+
@klass_name = target_parsed[:klass_name]
|
52
|
+
@method_name = target_parsed[:method_name]
|
53
|
+
@kind = target_parsed[:kind]
|
54
|
+
|
55
|
+
# Freeze for immutability
|
56
|
+
freeze
|
57
|
+
end
|
58
|
+
|
59
|
+
# Check if route has specific option
|
60
|
+
# @param key [Symbol, String] Option key to check
|
61
|
+
# @return [Boolean]
|
62
|
+
def has_option?(key)
|
63
|
+
@options.key?(key.to_sym)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Get option value with optional default
|
67
|
+
# @param key [Symbol, String] Option key
|
68
|
+
# @param default [Object] Default value if option not present
|
69
|
+
# @return [Object]
|
70
|
+
def option(key, default = nil)
|
71
|
+
@options.fetch(key.to_sym, default)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Get authentication requirement
|
75
|
+
# @return [String, nil] The auth requirement or nil
|
76
|
+
def auth_requirement
|
77
|
+
option(:auth)
|
78
|
+
end
|
79
|
+
|
80
|
+
# Get response type
|
81
|
+
# @return [String] The response type (defaults to 'default')
|
82
|
+
def response_type
|
83
|
+
option(:response, 'default')
|
84
|
+
end
|
85
|
+
|
86
|
+
# Check if CSRF is exempt for this route
|
87
|
+
# @return [Boolean]
|
88
|
+
def csrf_exempt?
|
89
|
+
option(:csrf) == 'exempt'
|
90
|
+
end
|
91
|
+
|
92
|
+
# Check if this is a Logic class route (no . or # in target)
|
93
|
+
# @return [Boolean]
|
94
|
+
def logic_route?
|
95
|
+
kind == :logic
|
96
|
+
end
|
97
|
+
|
98
|
+
# Create a new RouteDefinition with modified options
|
99
|
+
# @param new_options [Hash] Options to merge/override
|
100
|
+
# @return [RouteDefinition] New immutable instance
|
101
|
+
def with_options(new_options)
|
102
|
+
merged_options = @options.merge(new_options)
|
103
|
+
new_definition = [@target, *merged_options.map { |k, v| "#{k}=#{v}" }].join(' ')
|
104
|
+
|
105
|
+
self.class.new(@verb, @path, new_definition, pattern: @pattern, keys: @keys)
|
106
|
+
end
|
107
|
+
|
108
|
+
# Convert to hash representation
|
109
|
+
# @return [Hash]
|
110
|
+
def to_h
|
111
|
+
{
|
112
|
+
verb: @verb,
|
113
|
+
path: @path,
|
114
|
+
definition: @definition,
|
115
|
+
target: @target,
|
116
|
+
klass_name: @klass_name,
|
117
|
+
method_name: @method_name,
|
118
|
+
kind: @kind,
|
119
|
+
options: @options,
|
120
|
+
pattern: @pattern,
|
121
|
+
keys: @keys
|
122
|
+
}
|
123
|
+
end
|
124
|
+
|
125
|
+
# String representation for debugging
|
126
|
+
# @return [String]
|
127
|
+
def to_s
|
128
|
+
"#{@verb} #{@path} #{@definition}"
|
129
|
+
end
|
130
|
+
|
131
|
+
# Detailed inspection
|
132
|
+
# @return [String]
|
133
|
+
def inspect
|
134
|
+
"#<Otto::RouteDefinition #{to_s} options=#{@options.inspect}>"
|
135
|
+
end
|
136
|
+
|
137
|
+
private
|
138
|
+
|
139
|
+
# Parse route definition into target and options
|
140
|
+
# @param definition [String] The route definition
|
141
|
+
# @return [Hash] Hash with :target and :options keys
|
142
|
+
def parse_definition(definition)
|
143
|
+
parts = definition.split(/\s+/)
|
144
|
+
target = parts.shift
|
145
|
+
options = {}
|
146
|
+
|
147
|
+
parts.each do |part|
|
148
|
+
key, value = part.split('=', 2)
|
149
|
+
if key && value
|
150
|
+
options[key.to_sym] = value
|
151
|
+
else
|
152
|
+
# Malformed parameter, log warning if debug enabled
|
153
|
+
Otto.logger.warn "Ignoring malformed route parameter: #{part}" if Otto.debug
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
{ target: target, options: options }
|
158
|
+
end
|
159
|
+
|
160
|
+
# Parse target into class name, method name, and kind
|
161
|
+
# @param target [String] The target definition (e.g., "TestApp.index")
|
162
|
+
# @return [Hash] Hash with :klass_name, :method_name, and :kind
|
163
|
+
def parse_target(target)
|
164
|
+
if target.include?('.')
|
165
|
+
klass_name, method_name = target.split('.', 2)
|
166
|
+
{ klass_name: klass_name, method_name: method_name, kind: :class }
|
167
|
+
elsif target.include?('#')
|
168
|
+
klass_name, method_name = target.split('#', 2)
|
169
|
+
{ klass_name: klass_name, method_name: method_name, kind: :instance }
|
170
|
+
elsif target.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
|
171
|
+
# Namespaced class with implicit method name (class method with same name as class)
|
172
|
+
# E.g., "V2::Logic::Admin::Panel" -> Panel.Panel (class method)
|
173
|
+
# For single word classes like "Logic", it's truly a logic class
|
174
|
+
method_name = target.split('::').last
|
175
|
+
if target.include?('::')
|
176
|
+
# Namespaced class - treat as class method with implicit method name
|
177
|
+
{ klass_name: target, method_name: method_name, kind: :class }
|
178
|
+
else
|
179
|
+
# Single word class - treat as logic class
|
180
|
+
{ klass_name: target, method_name: method_name, kind: :logic }
|
181
|
+
end
|
182
|
+
else
|
183
|
+
raise ArgumentError, "Invalid target format: #{target}"
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|