otto 1.3.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/.github/dependabot.yml +15 -0
- data/.github/workflows/ci.yml +34 -0
- data/.gitignore +1 -0
- data/.pre-commit-config.yaml +107 -0
- data/.pre-push-config.yaml +88 -0
- data/.rubocop.yml +1 -1
- data/Gemfile +1 -3
- data/Gemfile.lock +5 -3
- data/README.md +58 -2
- data/docs/.gitignore +2 -0
- data/examples/helpers_demo/app.rb +244 -0
- data/examples/helpers_demo/config.ru +26 -0
- data/examples/helpers_demo/routes +7 -0
- data/lib/otto/helpers/base.rb +27 -0
- data/lib/otto/helpers/request.rb +223 -4
- data/lib/otto/helpers/response.rb +75 -0
- data/lib/otto/response_handlers.rb +141 -0
- data/lib/otto/route.rb +125 -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/security/config.rb +99 -1
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +143 -3
- data/otto.gemspec +2 -2
- metadata +29 -2
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
|
#
|
@@ -115,6 +150,15 @@ class Otto
|
|
115
150
|
res.extend Otto::ResponseHelpers
|
116
151
|
res.request = req
|
117
152
|
|
153
|
+
# Make security config available to response helpers
|
154
|
+
if otto.respond_to?(:security_config) && otto.security_config
|
155
|
+
env['otto.security_config'] = otto.security_config
|
156
|
+
end
|
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
|
+
|
118
162
|
# Process parameters through security layer
|
119
163
|
req.params.merge! extra_params
|
120
164
|
req.params.replace Otto::Static.indifferent_params(req.params)
|
@@ -137,53 +181,80 @@ class Otto
|
|
137
181
|
# Add validation helpers
|
138
182
|
res.extend Otto::Security::ValidationHelpers
|
139
183
|
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
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)
|
146
189
|
else
|
147
|
-
|
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
|
148
216
|
end
|
149
|
-
res.body = [res.body] unless res.body.respond_to?(:each)
|
150
|
-
res.finish
|
151
217
|
end
|
152
218
|
|
153
219
|
private
|
154
220
|
|
155
|
-
# Brazenly borrowed from Sinatra::Base:
|
156
|
-
# https://github.com/sinatra/sinatra/blob/v1.2.6/lib/sinatra/base.rb#L1156
|
157
221
|
def compile(path)
|
158
222
|
keys = []
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
case match
|
164
|
-
when '*'
|
165
|
-
keys << 'splat'
|
166
|
-
'(.*?)'
|
167
|
-
when *special_chars
|
168
|
-
Regexp.escape(match)
|
169
|
-
else
|
170
|
-
keys << ::Regexp.last_match(2)[1..-1]
|
171
|
-
'([^/?#]+)'
|
172
|
-
end
|
173
|
-
end
|
174
|
-
# Wrap the regex in parens so the regex works properly.
|
175
|
-
# They can fail when there's an | for example (matching only the last one).
|
176
|
-
# Note: this means we also need to remove the first matched value.
|
177
|
-
[/\A(#{pattern})\z/, keys]
|
178
|
-
elsif path.respond_to?(:keys) && path.respond_to?(:match)
|
179
|
-
[path, path.keys]
|
180
|
-
elsif path.respond_to?(:names) && path.respond_to?(:match)
|
181
|
-
[path, path.names]
|
182
|
-
elsif path.respond_to? :match
|
183
|
-
[path, keys]
|
223
|
+
|
224
|
+
# Handle string paths first (most common case)
|
225
|
+
if path.respond_to?(:to_str)
|
226
|
+
compile_string_path(path, keys)
|
184
227
|
else
|
185
|
-
|
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
|
186
238
|
end
|
187
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
|
188
259
|
end
|
189
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
|