otto 2.0.0.pre8 → 2.0.0.pre9
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/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/CHANGELOG.rst +54 -35
- data/Gemfile.lock +6 -6
- data/README.md +20 -0
- data/docs/.gitignore +2 -0
- data/docs/modern-authentication-authorization-landscape.md +558 -0
- data/docs/multi-strategy-authentication-design.md +1401 -0
- data/lib/otto/core/error_handler.rb +19 -8
- data/lib/otto/core/freezable.rb +0 -2
- data/lib/otto/core/middleware_stack.rb +12 -8
- data/lib/otto/core/router.rb +25 -31
- data/lib/otto/errors.rb +92 -0
- data/lib/otto/mcp/rate_limiting.rb +6 -2
- data/lib/otto/mcp/schema_validation.rb +1 -1
- data/lib/otto/response_handlers/json.rb +1 -3
- data/lib/otto/response_handlers/view.rb +1 -1
- data/lib/otto/route_handlers/base.rb +86 -1
- data/lib/otto/route_handlers/class_method.rb +9 -67
- data/lib/otto/route_handlers/instance_method.rb +10 -57
- data/lib/otto/route_handlers/logic_class.rb +85 -90
- data/lib/otto/security/authentication/auth_strategy.rb +2 -2
- data/lib/otto/security/authentication/strategy_result.rb +9 -9
- data/lib/otto/security/authorization_error.rb +1 -1
- data/lib/otto/security/config.rb +3 -3
- data/lib/otto/security/rate_limiter.rb +7 -3
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +47 -3
- metadata +4 -1
|
@@ -26,7 +26,7 @@ class Otto
|
|
|
26
26
|
log_context = base_context.merge(
|
|
27
27
|
error: error.message,
|
|
28
28
|
error_class: error.class.name,
|
|
29
|
-
error_id: error_id
|
|
29
|
+
error_id: error_id,
|
|
30
30
|
)
|
|
31
31
|
log_context[:handler] = env['otto.handler'] if env['otto.handler']
|
|
32
32
|
log_context[:duration] = env['otto.handler_duration'] if env['otto.handler_duration']
|
|
@@ -69,8 +69,7 @@ class Otto
|
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
# Content negotiation for built-in error response
|
|
72
|
-
|
|
73
|
-
return json_error_response(error_id) if accept_header.include?('application/json')
|
|
72
|
+
return json_error_response(error_id) if wants_json_response?(env)
|
|
74
73
|
|
|
75
74
|
# Fallback to built-in error response
|
|
76
75
|
@server_error || secure_error_response(error_id)
|
|
@@ -96,7 +95,7 @@ class Otto
|
|
|
96
95
|
error: error.message,
|
|
97
96
|
error_class: error.class.name,
|
|
98
97
|
error_id: error_id,
|
|
99
|
-
expected: true
|
|
98
|
+
expected: true # Mark as expected error
|
|
100
99
|
)
|
|
101
100
|
log_context[:handler] = env['otto.handler'] if env['otto.handler']
|
|
102
101
|
log_context[:duration] = env['otto.handler_duration'] if env['otto.handler_duration']
|
|
@@ -146,14 +145,13 @@ class Otto
|
|
|
146
145
|
response_body[:error_id] = error_id if Otto.env?(:dev, :development)
|
|
147
146
|
|
|
148
147
|
# Content negotiation
|
|
149
|
-
accept_header = env['HTTP_ACCEPT'].to_s
|
|
150
148
|
status = handler_config[:status] || 500
|
|
151
149
|
|
|
152
|
-
if
|
|
150
|
+
if wants_json_response?(env)
|
|
153
151
|
body = JSON.generate(response_body)
|
|
154
152
|
headers = {
|
|
155
153
|
'content-type' => 'application/json',
|
|
156
|
-
'content-length' => body.bytesize.to_s
|
|
154
|
+
'content-length' => body.bytesize.to_s,
|
|
157
155
|
}.merge(@security_config.security_headers)
|
|
158
156
|
|
|
159
157
|
[status, headers, [body]]
|
|
@@ -167,7 +165,7 @@ class Otto
|
|
|
167
165
|
|
|
168
166
|
headers = {
|
|
169
167
|
'content-type' => 'text/plain',
|
|
170
|
-
'content-length' => body.bytesize.to_s
|
|
168
|
+
'content-length' => body.bytesize.to_s,
|
|
171
169
|
}.merge(@security_config.security_headers)
|
|
172
170
|
|
|
173
171
|
[status, headers, [body]]
|
|
@@ -211,6 +209,19 @@ class Otto
|
|
|
211
209
|
|
|
212
210
|
[500, headers, [body]]
|
|
213
211
|
end
|
|
212
|
+
|
|
213
|
+
private
|
|
214
|
+
|
|
215
|
+
# Determine if the client wants a JSON response
|
|
216
|
+
# Route's response_type declaration takes precedence over Accept header
|
|
217
|
+
#
|
|
218
|
+
# @param env [Hash] Rack environment
|
|
219
|
+
# @return [Boolean] true if JSON response is preferred
|
|
220
|
+
def wants_json_response?(env)
|
|
221
|
+
route_definition = env['otto.route_definition']
|
|
222
|
+
(route_definition&.response_type == 'json') ||
|
|
223
|
+
env['HTTP_ACCEPT'].to_s.include?('application/json')
|
|
224
|
+
end
|
|
214
225
|
end
|
|
215
226
|
end
|
|
216
227
|
end
|
data/lib/otto/core/freezable.rb
CHANGED
|
@@ -73,7 +73,7 @@ class Otto
|
|
|
73
73
|
when :last
|
|
74
74
|
@stack << entry
|
|
75
75
|
else
|
|
76
|
-
@stack << entry
|
|
76
|
+
@stack << entry # Default append
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
@middleware_set.add(middleware_class)
|
|
@@ -114,15 +114,21 @@ class Otto
|
|
|
114
114
|
|
|
115
115
|
# Check optimal order: rate_limit < auth < validation
|
|
116
116
|
if rate_limit_pos && auth_pos && rate_limit_pos > auth_pos
|
|
117
|
-
warnings <<
|
|
117
|
+
warnings << <<~MSG.chomp
|
|
118
|
+
[MCP Middleware] RateLimitMiddleware should come before TokenMiddleware
|
|
119
|
+
MSG
|
|
118
120
|
end
|
|
119
121
|
|
|
120
122
|
if auth_pos && validation_pos && auth_pos > validation_pos
|
|
121
|
-
warnings <<
|
|
123
|
+
warnings << <<~MSG.chomp
|
|
124
|
+
[MCP Middleware] TokenMiddleware should come before SchemaValidationMiddleware
|
|
125
|
+
MSG
|
|
122
126
|
end
|
|
123
127
|
|
|
124
128
|
if rate_limit_pos && validation_pos && rate_limit_pos > validation_pos
|
|
125
|
-
warnings <<
|
|
129
|
+
warnings << <<~MSG.chomp
|
|
130
|
+
[MCP Middleware] RateLimitMiddleware should come before SchemaValidationMiddleware
|
|
131
|
+
MSG
|
|
126
132
|
end
|
|
127
133
|
|
|
128
134
|
warnings
|
|
@@ -198,8 +204,8 @@ class Otto
|
|
|
198
204
|
@stack.map do |entry|
|
|
199
205
|
{
|
|
200
206
|
middleware: entry[:middleware],
|
|
201
|
-
|
|
202
|
-
|
|
207
|
+
args: entry[:args],
|
|
208
|
+
options: entry[:options],
|
|
203
209
|
}
|
|
204
210
|
end
|
|
205
211
|
end
|
|
@@ -225,8 +231,6 @@ class Otto
|
|
|
225
231
|
@stack.reverse_each(&)
|
|
226
232
|
end
|
|
227
233
|
|
|
228
|
-
|
|
229
|
-
|
|
230
234
|
private
|
|
231
235
|
|
|
232
236
|
def middleware_needs_config?(middleware_class)
|
data/lib/otto/core/router.rb
CHANGED
|
@@ -36,28 +36,28 @@ class Otto
|
|
|
36
36
|
route.otto = self
|
|
37
37
|
path_clean = path.gsub(%r{/$}, '')
|
|
38
38
|
@route_definitions[route.definition] = route
|
|
39
|
-
Otto.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
if Otto.debug
|
|
40
|
+
Otto.structured_log(:debug, 'Route loaded',
|
|
41
|
+
{
|
|
42
|
+
pattern: route.pattern.source,
|
|
43
|
+
verb: route.verb,
|
|
44
|
+
definition: route.definition,
|
|
45
|
+
type: 'pattern',
|
|
46
|
+
})
|
|
47
|
+
end
|
|
47
48
|
@routes[route.verb] ||= []
|
|
48
49
|
@routes[route.verb] << route
|
|
49
50
|
@routes_literal[route.verb] ||= {}
|
|
50
51
|
@routes_literal[route.verb][path_clean] = route
|
|
51
52
|
rescue StandardError => e
|
|
52
|
-
Otto.structured_log(:error,
|
|
53
|
+
Otto.structured_log(:error, 'Route load failed',
|
|
53
54
|
{
|
|
54
|
-
|
|
55
|
+
path: path,
|
|
55
56
|
verb: verb,
|
|
56
57
|
definition: definition,
|
|
57
58
|
error: e.message,
|
|
58
|
-
error_class: e.class.name
|
|
59
|
-
}
|
|
60
|
-
)
|
|
59
|
+
error_class: e.class.name,
|
|
60
|
+
})
|
|
61
61
|
Otto.logger.debug e.backtrace.join("\n") if Otto.debug
|
|
62
62
|
end
|
|
63
63
|
self
|
|
@@ -96,30 +96,27 @@ class Otto
|
|
|
96
96
|
literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
|
|
97
97
|
|
|
98
98
|
if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
|
|
99
|
-
Otto.structured_log(:debug,
|
|
99
|
+
Otto.structured_log(:debug, 'Route matched',
|
|
100
100
|
Otto::LoggingHelpers.request_context(env).merge(
|
|
101
101
|
type: 'static_cached',
|
|
102
102
|
base_path: base_path
|
|
103
|
-
)
|
|
104
|
-
)
|
|
103
|
+
))
|
|
105
104
|
static_route.call(env)
|
|
106
105
|
elsif literal_routes.has_key?(path_info_clean)
|
|
107
106
|
route = literal_routes[path_info_clean]
|
|
108
|
-
Otto.structured_log(:debug,
|
|
107
|
+
Otto.structured_log(:debug, 'Route matched',
|
|
109
108
|
Otto::LoggingHelpers.request_context(env).merge(
|
|
110
109
|
type: 'literal',
|
|
111
110
|
handler: route.route_definition.definition,
|
|
112
111
|
auth_strategy: route.route_definition.auth_requirement || 'none'
|
|
113
|
-
)
|
|
114
|
-
)
|
|
112
|
+
))
|
|
115
113
|
route.call(env)
|
|
116
114
|
elsif static_route && http_verb == :GET && safe_file?(path_info)
|
|
117
|
-
Otto.structured_log(:debug,
|
|
115
|
+
Otto.structured_log(:debug, 'Route matched',
|
|
118
116
|
Otto::LoggingHelpers.request_context(env).merge(
|
|
119
117
|
type: 'static_new',
|
|
120
118
|
base_path: base_path
|
|
121
|
-
)
|
|
122
|
-
)
|
|
119
|
+
))
|
|
123
120
|
routes_static[:GET][base_path] = base_path
|
|
124
121
|
static_route.call(env)
|
|
125
122
|
else
|
|
@@ -165,14 +162,13 @@ class Otto
|
|
|
165
162
|
found_route = route
|
|
166
163
|
|
|
167
164
|
# Log successful route match
|
|
168
|
-
Otto.structured_log(:debug,
|
|
165
|
+
Otto.structured_log(:debug, 'Route matched',
|
|
169
166
|
Otto::LoggingHelpers.request_context(env).merge(
|
|
170
167
|
pattern: route.pattern.source,
|
|
171
168
|
handler: route.route_definition.definition,
|
|
172
169
|
auth_strategy: route.route_definition.auth_requirement || 'none',
|
|
173
170
|
route_params: extra_params
|
|
174
|
-
)
|
|
175
|
-
)
|
|
171
|
+
))
|
|
176
172
|
break
|
|
177
173
|
end
|
|
178
174
|
|
|
@@ -180,19 +176,17 @@ class Otto
|
|
|
180
176
|
if found_route
|
|
181
177
|
# Log 404 route usage if we fell back to it
|
|
182
178
|
if found_route == literal_routes['/404']
|
|
183
|
-
Otto.structured_log(:info,
|
|
179
|
+
Otto.structured_log(:info, 'Route not found',
|
|
184
180
|
Otto::LoggingHelpers.request_context(env).merge(
|
|
185
181
|
fallback_to: '404_route'
|
|
186
|
-
)
|
|
187
|
-
)
|
|
182
|
+
))
|
|
188
183
|
end
|
|
189
184
|
found_route.call env, extra_params
|
|
190
185
|
else
|
|
191
|
-
Otto.structured_log(:info,
|
|
186
|
+
Otto.structured_log(:info, 'Route not found',
|
|
192
187
|
Otto::LoggingHelpers.request_context(env).merge(
|
|
193
188
|
fallback_to: 'default_not_found'
|
|
194
|
-
)
|
|
195
|
-
)
|
|
189
|
+
))
|
|
196
190
|
@not_found || Otto::Static.not_found
|
|
197
191
|
end
|
|
198
192
|
end
|
data/lib/otto/errors.rb
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Base error classes for Otto framework
|
|
4
|
+
#
|
|
5
|
+
# These classes provide a foundation for HTTP error handling and can be
|
|
6
|
+
# subclassed by implementing projects for consistent error handling.
|
|
7
|
+
#
|
|
8
|
+
# @example Subclassing in an application
|
|
9
|
+
# class MyApp::ResourceNotFound < Otto::NotFoundError; end
|
|
10
|
+
#
|
|
11
|
+
# otto.register_error_handler(MyApp::ResourceNotFound, status: 404, log_level: :info)
|
|
12
|
+
#
|
|
13
|
+
class Otto
|
|
14
|
+
# Base class for all Otto HTTP errors
|
|
15
|
+
#
|
|
16
|
+
# Provides default_status and default_log_level class methods that
|
|
17
|
+
# define the HTTP status code and logging level for the error.
|
|
18
|
+
class HTTPError < StandardError
|
|
19
|
+
def self.default_status
|
|
20
|
+
500
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.default_log_level
|
|
24
|
+
:error
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Bad Request (400) error
|
|
29
|
+
#
|
|
30
|
+
# Use for malformed requests, invalid parameters, or failed validation
|
|
31
|
+
class BadRequestError < HTTPError
|
|
32
|
+
def self.default_status
|
|
33
|
+
400
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.default_log_level
|
|
37
|
+
:info
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Unauthorized (401) error
|
|
42
|
+
#
|
|
43
|
+
# Use when authentication is required but missing or invalid
|
|
44
|
+
class UnauthorizedError < HTTPError
|
|
45
|
+
def self.default_status
|
|
46
|
+
401
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.default_log_level
|
|
50
|
+
:info
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Forbidden (403) error
|
|
55
|
+
#
|
|
56
|
+
# Use when the user is authenticated but lacks permission
|
|
57
|
+
class ForbiddenError < HTTPError
|
|
58
|
+
def self.default_status
|
|
59
|
+
403
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.default_log_level
|
|
63
|
+
:warn
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Not Found (404) error
|
|
68
|
+
#
|
|
69
|
+
# Use when the requested resource does not exist
|
|
70
|
+
class NotFoundError < HTTPError
|
|
71
|
+
def self.default_status
|
|
72
|
+
404
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.default_log_level
|
|
76
|
+
:info
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Payload Too Large (413) error
|
|
81
|
+
#
|
|
82
|
+
# Use when request body exceeds configured size limits
|
|
83
|
+
class PayloadTooLargeError < HTTPError
|
|
84
|
+
def self.default_status
|
|
85
|
+
413
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.default_log_level
|
|
89
|
+
:warn
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -87,8 +87,12 @@ class Otto
|
|
|
87
87
|
[429, headers, [JSON.generate(error_response)]]
|
|
88
88
|
else
|
|
89
89
|
# Use the general rate limiting response for non-MCP requests
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
# Route's response_type takes precedence over Accept header
|
|
91
|
+
route_def = request.env['otto.route_definition']
|
|
92
|
+
wants_json = (route_def&.response_type == 'json') ||
|
|
93
|
+
request.env['HTTP_ACCEPT'].to_s.include?('application/json')
|
|
94
|
+
|
|
95
|
+
if wants_json
|
|
92
96
|
error_response = {
|
|
93
97
|
error: 'Rate limit exceeded',
|
|
94
98
|
message: 'Too many requests',
|
|
@@ -11,9 +11,7 @@ class Otto
|
|
|
11
11
|
def self.handle(result, response, context = {})
|
|
12
12
|
# If a redirect has already been set, don't override with JSON
|
|
13
13
|
# This allows controllers to conditionally redirect based on Accept header
|
|
14
|
-
if response.status&.between?(300, 399) && response['Location']
|
|
15
|
-
return
|
|
16
|
-
end
|
|
14
|
+
return if response.status&.between?(300, 399) && response['Location']
|
|
17
15
|
|
|
18
16
|
response['Content-Type'] = 'application/json'
|
|
19
17
|
|
|
@@ -9,7 +9,7 @@ class Otto
|
|
|
9
9
|
# Handler for view/template responses
|
|
10
10
|
class ViewHandler < BaseHandler
|
|
11
11
|
def self.handle(result, response, context = {})
|
|
12
|
-
if context[:logic_instance]
|
|
12
|
+
if context[:logic_instance].respond_to?(:view)
|
|
13
13
|
response.body = [context[:logic_instance].view.render]
|
|
14
14
|
response['Content-Type'] = 'text/html' unless response['Content-Type']
|
|
15
15
|
elsif result.respond_to?(:to_s)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require 'json'
|
|
6
|
+
require 'securerandom'
|
|
6
7
|
|
|
7
8
|
class Otto
|
|
8
9
|
module RouteHandlers
|
|
@@ -21,7 +22,20 @@ class Otto
|
|
|
21
22
|
# @param extra_params [Hash] Additional parameters
|
|
22
23
|
# @return [Array] Rack response array
|
|
23
24
|
def call(env, extra_params = {})
|
|
24
|
-
|
|
25
|
+
@start_time = Otto::Utils.now_in_μs
|
|
26
|
+
req = Rack::Request.new(env)
|
|
27
|
+
res = Rack::Response.new
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
setup_request_response(req, res, env, extra_params)
|
|
31
|
+
result, context = invoke_target(req, res)
|
|
32
|
+
|
|
33
|
+
handle_response(result, res, context) if route_definition.response_type != 'default'
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
handle_execution_error(e, env, req, res, @start_time)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
finalize_response(res)
|
|
25
39
|
end
|
|
26
40
|
|
|
27
41
|
protected
|
|
@@ -32,6 +46,77 @@ class Otto
|
|
|
32
46
|
@target_class ||= safe_const_get(route_definition.klass_name)
|
|
33
47
|
end
|
|
34
48
|
|
|
49
|
+
# Template method for subclasses to implement their invocation logic
|
|
50
|
+
# @param req [Rack::Request] Request object
|
|
51
|
+
# @param res [Rack::Response] Response object
|
|
52
|
+
# @return [Array] [result, context] where context is a hash for handle_response
|
|
53
|
+
def invoke_target(req, res)
|
|
54
|
+
raise NotImplementedError, 'Subclasses must implement #invoke_target'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Handle errors during route execution
|
|
58
|
+
# @param error [StandardError] The error that occurred
|
|
59
|
+
# @param env [Hash] Rack environment
|
|
60
|
+
# @param req [Rack::Request] Request object
|
|
61
|
+
# @param res [Rack::Response] Response object
|
|
62
|
+
# @param start_time [Integer] Start time in microseconds
|
|
63
|
+
def handle_execution_error(error, env, _req, res, start_time)
|
|
64
|
+
if otto_instance
|
|
65
|
+
# Integrated context - let centralized error handler manage
|
|
66
|
+
env['otto.handler'] = handler_name
|
|
67
|
+
env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
|
|
68
|
+
raise error
|
|
69
|
+
else
|
|
70
|
+
# Direct testing context - handle locally
|
|
71
|
+
handle_local_error(error, env, res)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Handle errors locally for testing context
|
|
76
|
+
# @param error [StandardError] The error that occurred
|
|
77
|
+
# @param env [Hash] Rack environment
|
|
78
|
+
# @param res [Rack::Response] Response object
|
|
79
|
+
def handle_local_error(error, env, res)
|
|
80
|
+
error_id = SecureRandom.hex(8)
|
|
81
|
+
Otto.logger.error "[#{error_id}] #{error.class}: #{error.message}"
|
|
82
|
+
Otto.logger.debug "[#{error_id}] Backtrace: #{error.backtrace.join("\n")}" if Otto.debug
|
|
83
|
+
|
|
84
|
+
res.status = 500
|
|
85
|
+
|
|
86
|
+
# Content negotiation for error response
|
|
87
|
+
# Route's response_type takes precedence over Accept header
|
|
88
|
+
route_def = env['otto.route_definition']
|
|
89
|
+
wants_json = (route_def&.response_type == 'json') ||
|
|
90
|
+
env['HTTP_ACCEPT'].to_s.include?('application/json')
|
|
91
|
+
|
|
92
|
+
if wants_json
|
|
93
|
+
res.headers['content-type'] = 'application/json'
|
|
94
|
+
error_data = {
|
|
95
|
+
error: 'Internal Server Error',
|
|
96
|
+
message: 'Server error occurred. Check logs for details.',
|
|
97
|
+
error_id: error_id,
|
|
98
|
+
}
|
|
99
|
+
res.write JSON.generate(error_data)
|
|
100
|
+
else
|
|
101
|
+
res.headers['content-type'] = 'text/plain'
|
|
102
|
+
if Otto.env?(:dev, :development)
|
|
103
|
+
res.write "Server error (ID: #{error_id}). Check logs for details."
|
|
104
|
+
else
|
|
105
|
+
res.write 'An error occurred. Please try again later.'
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Security headers are not available without an otto_instance
|
|
110
|
+
# (testing/local context). The RouteAuthWrapper handles security
|
|
111
|
+
# headers when otto_instance is present.
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Format the handler name for logging
|
|
115
|
+
# @return [String] Handler name in format "ClassName#method_name"
|
|
116
|
+
def handler_name
|
|
117
|
+
"#{target_class.name}##{route_definition.method_name}"
|
|
118
|
+
end
|
|
119
|
+
|
|
35
120
|
# Setup request and response with the same extensions and processing as Route#call
|
|
36
121
|
# @param req [Rack::Request] Request object
|
|
37
122
|
# @param res [Rack::Response] Response object
|
|
@@ -2,9 +2,6 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
require 'json'
|
|
6
|
-
require 'securerandom'
|
|
7
|
-
|
|
8
5
|
require_relative 'base'
|
|
9
6
|
|
|
10
7
|
class Otto
|
|
@@ -19,70 +16,15 @@ class Otto
|
|
|
19
16
|
# Use this handler for endpoints requiring request-level control (logout,
|
|
20
17
|
# session management, cookie manipulation, custom header handling).
|
|
21
18
|
class ClassMethodHandler < BaseHandler
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
# Call class method directly (existing Otto behavior)
|
|
32
|
-
result = target_class.send(route_definition.method_name, req, res)
|
|
33
|
-
|
|
34
|
-
# Only handle response if response_type is not default
|
|
35
|
-
if route_definition.response_type != 'default'
|
|
36
|
-
handle_response(result, res,
|
|
37
|
-
{
|
|
38
|
-
class: target_class,
|
|
39
|
-
request: req,
|
|
40
|
-
})
|
|
41
|
-
end
|
|
42
|
-
rescue StandardError => e
|
|
43
|
-
# Check if we're being called through Otto's integrated context (vs direct handler testing)
|
|
44
|
-
# In integrated context, let Otto's centralized error handler manage the response
|
|
45
|
-
# In direct testing context, handle errors locally for unit testing
|
|
46
|
-
if otto_instance
|
|
47
|
-
# Store handler context in env for centralized error handler
|
|
48
|
-
handler_name = "#{target_class.name}##{route_definition.method_name}"
|
|
49
|
-
env['otto.handler'] = handler_name
|
|
50
|
-
env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
|
|
51
|
-
|
|
52
|
-
raise e # Re-raise to let Otto's centralized error handler manage the response
|
|
53
|
-
else
|
|
54
|
-
# Direct handler testing context - handle errors locally with security improvements
|
|
55
|
-
error_id = SecureRandom.hex(8)
|
|
56
|
-
Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
|
|
57
|
-
Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
|
|
58
|
-
|
|
59
|
-
res.status = 500
|
|
60
|
-
|
|
61
|
-
# Content negotiation for error response
|
|
62
|
-
accept_header = env['HTTP_ACCEPT'].to_s
|
|
63
|
-
if accept_header.include?('application/json')
|
|
64
|
-
res.headers['content-type'] = 'application/json'
|
|
65
|
-
error_data = {
|
|
66
|
-
error: 'Internal Server Error',
|
|
67
|
-
message: 'Server error occurred. Check logs for details.',
|
|
68
|
-
error_id: error_id,
|
|
69
|
-
}
|
|
70
|
-
res.write JSON.generate(error_data)
|
|
71
|
-
else
|
|
72
|
-
res.headers['content-type'] = 'text/plain'
|
|
73
|
-
res.write "Server error (ID: #{error_id}). Check logs for details."
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# Add security headers if available
|
|
77
|
-
if otto_instance.respond_to?(:security_config) && otto_instance.security_config
|
|
78
|
-
otto_instance.security_config.security_headers.each do |header, value|
|
|
79
|
-
res.headers[header] = value
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
finalize_response(res)
|
|
19
|
+
protected
|
|
20
|
+
|
|
21
|
+
# Invoke the class method on the target class
|
|
22
|
+
# @param req [Rack::Request] Request object
|
|
23
|
+
# @param res [Rack::Response] Response object
|
|
24
|
+
# @return [Array] [result, context] for handle_response
|
|
25
|
+
def invoke_target(req, res)
|
|
26
|
+
result = target_class.send(route_definition.method_name, req, res)
|
|
27
|
+
[result, { class: target_class, request: req }]
|
|
86
28
|
end
|
|
87
29
|
end
|
|
88
30
|
end
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# lib/otto/route_handlers/instance_method.rb
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
|
-
require 'securerandom'
|
|
5
4
|
|
|
6
5
|
require_relative 'base'
|
|
7
6
|
|
|
@@ -17,62 +16,16 @@ class Otto
|
|
|
17
16
|
# Use this handler for endpoints requiring request-level control (logout,
|
|
18
17
|
# session management, cookie manipulation, custom header handling).
|
|
19
18
|
class InstanceMethodHandler < BaseHandler
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
instance = target_class.new(req, res)
|
|
31
|
-
result = instance.send(route_definition.method_name)
|
|
32
|
-
|
|
33
|
-
# Only handle response if response_type is not default
|
|
34
|
-
if route_definition.response_type != 'default'
|
|
35
|
-
handle_response(result, res, {
|
|
36
|
-
instance: instance,
|
|
37
|
-
request: req,
|
|
38
|
-
})
|
|
39
|
-
end
|
|
40
|
-
rescue StandardError => e
|
|
41
|
-
# Check if we're being called through Otto's integrated context (vs direct handler testing)
|
|
42
|
-
# In integrated context, let Otto's centralized error handler manage the response
|
|
43
|
-
# In direct testing context, handle errors locally for unit testing
|
|
44
|
-
if otto_instance
|
|
45
|
-
# Store handler context in env for centralized error handler
|
|
46
|
-
handler_name = "#{target_class}##{route_definition.method_name}"
|
|
47
|
-
env['otto.handler'] = handler_name
|
|
48
|
-
env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
|
|
49
|
-
|
|
50
|
-
raise e # Re-raise to let Otto's centralized error handler manage the response
|
|
51
|
-
else
|
|
52
|
-
# Direct handler testing context - handle errors locally with security improvements
|
|
53
|
-
error_id = SecureRandom.hex(8)
|
|
54
|
-
Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
|
|
55
|
-
Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
|
|
56
|
-
|
|
57
|
-
res.status = 500
|
|
58
|
-
res.headers['content-type'] = 'text/plain'
|
|
59
|
-
|
|
60
|
-
if Otto.env?(:dev, :development)
|
|
61
|
-
res.write "Server error (ID: #{error_id}). Check logs for details."
|
|
62
|
-
else
|
|
63
|
-
res.write 'An error occurred. Please try again later.'
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Add security headers if available
|
|
67
|
-
if otto_instance.respond_to?(:security_config) && otto_instance.security_config
|
|
68
|
-
otto_instance.security_config.security_headers.each do |header, value|
|
|
69
|
-
res.headers[header] = value
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
finalize_response(res)
|
|
19
|
+
protected
|
|
20
|
+
|
|
21
|
+
# Invoke the instance method on the target class
|
|
22
|
+
# @param req [Rack::Request] Request object
|
|
23
|
+
# @param res [Rack::Response] Response object
|
|
24
|
+
# @return [Array] [result, context] for handle_response
|
|
25
|
+
def invoke_target(req, res)
|
|
26
|
+
instance = target_class.new(req, res)
|
|
27
|
+
result = instance.send(route_definition.method_name)
|
|
28
|
+
[result, { instance: instance, request: req }]
|
|
76
29
|
end
|
|
77
30
|
end
|
|
78
31
|
end
|