otto 2.0.0.pre8 → 2.0.0.pre10
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 +73 -35
- data/CLAUDE.md +44 -0
- data/Gemfile.lock +7 -7
- 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 +104 -10
- data/lib/otto/core/freezable.rb +0 -2
- data/lib/otto/core/helper_registry.rb +135 -0
- data/lib/otto/core/lifecycle_hooks.rb +63 -0
- data/lib/otto/core/middleware_management.rb +70 -0
- data/lib/otto/core/middleware_stack.rb +12 -8
- data/lib/otto/core/router.rb +25 -31
- data/lib/otto/core.rb +3 -0
- data/lib/otto/errors.rb +92 -0
- data/lib/otto/helpers.rb +2 -2
- data/lib/otto/locale/middleware.rb +1 -1
- data/lib/otto/mcp/core.rb +33 -0
- data/lib/otto/mcp/protocol.rb +1 -1
- data/lib/otto/mcp/rate_limiting.rb +6 -2
- data/lib/otto/mcp/schema_validation.rb +2 -2
- data/lib/otto/mcp.rb +1 -0
- data/lib/otto/privacy/core.rb +82 -0
- data/lib/otto/privacy.rb +1 -0
- data/lib/otto/{helpers/request.rb → request.rb} +17 -6
- data/lib/otto/{helpers/response.rb → response.rb} +20 -7
- data/lib/otto/response_handlers/json.rb +1 -3
- data/lib/otto/response_handlers/view.rb +1 -1
- data/lib/otto/route.rb +2 -4
- data/lib/otto/route_handlers/base.rb +88 -5
- 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/lambda.rb +2 -2
- 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/strategies/api_key_strategy.rb +1 -1
- 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/core.rb +167 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +1 -1
- data/lib/otto/security/middleware/validation_middleware.rb +1 -1
- data/lib/otto/security/rate_limiter.rb +7 -3
- data/lib/otto/security.rb +1 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +29 -404
- metadata +12 -3
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
|
data/lib/otto/helpers.rb
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
# Request and response helpers are now defined directly in Otto::Request and Otto::Response
|
|
6
|
+
# This file is kept for backward compatibility with require statements
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# lib/otto/mcp/core.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module MCP
|
|
7
|
+
# Core MCP (Model Context Protocol) methods included in the Otto class.
|
|
8
|
+
# Provides the public API for enabling and querying MCP server support.
|
|
9
|
+
module Core
|
|
10
|
+
# Enable MCP (Model Context Protocol) server support
|
|
11
|
+
#
|
|
12
|
+
# @param options [Hash] MCP configuration options
|
|
13
|
+
# @option options [Boolean] :http Enable HTTP endpoint (default: true)
|
|
14
|
+
# @option options [Boolean] :stdio Enable STDIO communication (default: false)
|
|
15
|
+
# @option options [String] :endpoint HTTP endpoint path (default: '/_mcp')
|
|
16
|
+
# @example
|
|
17
|
+
# otto.enable_mcp!(http: true, endpoint: '/api/mcp')
|
|
18
|
+
def enable_mcp!(options = {})
|
|
19
|
+
ensure_not_frozen!
|
|
20
|
+
@mcp_server ||= Otto::MCP::Server.new(self)
|
|
21
|
+
|
|
22
|
+
@mcp_server.enable!(options)
|
|
23
|
+
Otto.logger.info '[MCP] Enabled MCP server' if Otto.debug
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Check if MCP is enabled
|
|
27
|
+
# @return [Boolean]
|
|
28
|
+
def mcp_enabled?
|
|
29
|
+
@mcp_server&.enabled?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/otto/mcp/protocol.rb
CHANGED
|
@@ -17,7 +17,7 @@ class Otto
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def handle_request(env)
|
|
20
|
-
request =
|
|
20
|
+
request = @otto.request_class.new(env)
|
|
21
21
|
|
|
22
22
|
unless request.post? && request.content_type&.include?('application/json')
|
|
23
23
|
return error_response(nil, -32_600, 'Invalid Request', 'Only JSON-RPC POST requests supported')
|
|
@@ -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',
|
|
@@ -12,7 +12,7 @@ end
|
|
|
12
12
|
|
|
13
13
|
class Otto
|
|
14
14
|
module MCP
|
|
15
|
-
class ValidationError <
|
|
15
|
+
class ValidationError < Otto::BadRequestError; end
|
|
16
16
|
|
|
17
17
|
# JSON Schema validator for MCP protocol requests
|
|
18
18
|
class Validator
|
|
@@ -78,7 +78,7 @@ class Otto
|
|
|
78
78
|
# Only validate MCP endpoints
|
|
79
79
|
return @app.call(env) unless mcp_endpoint?(env)
|
|
80
80
|
|
|
81
|
-
request =
|
|
81
|
+
request = Otto::Request.new(env)
|
|
82
82
|
|
|
83
83
|
if request.post? && request.content_type&.include?('application/json')
|
|
84
84
|
begin
|
data/lib/otto/mcp.rb
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# lib/otto/privacy/core.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Privacy
|
|
7
|
+
# Core privacy configuration methods included in the Otto class.
|
|
8
|
+
# Provides the public API for configuring IP privacy features.
|
|
9
|
+
module Core
|
|
10
|
+
# Disable IP privacy to access original IP addresses
|
|
11
|
+
#
|
|
12
|
+
# IMPORTANT: By default, Otto masks public IP addresses for privacy.
|
|
13
|
+
# Private/localhost IPs (127.0.0.0/8, 10.0.0.0/8, etc.) are never masked.
|
|
14
|
+
# Only disable this if you need access to original public IPs.
|
|
15
|
+
#
|
|
16
|
+
# When disabled:
|
|
17
|
+
# - env['REMOTE_ADDR'] contains the real IP address
|
|
18
|
+
# - env['otto.original_ip'] also contains the real IP
|
|
19
|
+
# - No PrivateFingerprint is created
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# otto.disable_ip_privacy!
|
|
23
|
+
def disable_ip_privacy!
|
|
24
|
+
ensure_not_frozen!
|
|
25
|
+
@security_config.ip_privacy_config.disable!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Enable full IP privacy (mask ALL IPs including private/localhost)
|
|
29
|
+
#
|
|
30
|
+
# By default, Otto exempts private and localhost IPs from masking for
|
|
31
|
+
# better development experience. Call this method to mask ALL IPs
|
|
32
|
+
# regardless of type.
|
|
33
|
+
#
|
|
34
|
+
# @example Enable full privacy (mask all IPs)
|
|
35
|
+
# otto = Otto.new(routes_file)
|
|
36
|
+
# otto.enable_full_ip_privacy!
|
|
37
|
+
# # Now 127.0.0.1 → 127.0.0.0, 192.168.1.100 → 192.168.1.0
|
|
38
|
+
#
|
|
39
|
+
# @return [void]
|
|
40
|
+
# @raise [FrozenError] if called after configuration is frozen
|
|
41
|
+
def enable_full_ip_privacy!
|
|
42
|
+
ensure_not_frozen!
|
|
43
|
+
@security_config.ip_privacy_config.mask_private_ips = true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Configure IP privacy settings
|
|
47
|
+
#
|
|
48
|
+
# Privacy is enabled by default. Use this method to customize privacy
|
|
49
|
+
# behavior without disabling it entirely.
|
|
50
|
+
#
|
|
51
|
+
# @param octet_precision [Integer] Number of octets to mask (1 or 2, default: 1)
|
|
52
|
+
# @param hash_rotation [Integer] Seconds between key rotation (default: 86400)
|
|
53
|
+
# @param geo [Boolean] Enable geo-location resolution (default: true)
|
|
54
|
+
# @param redis [Redis] Redis connection for multi-server atomic key generation
|
|
55
|
+
#
|
|
56
|
+
# @example Mask 2 octets instead of 1
|
|
57
|
+
# otto.configure_ip_privacy(octet_precision: 2)
|
|
58
|
+
#
|
|
59
|
+
# @example Disable geo-location
|
|
60
|
+
# otto.configure_ip_privacy(geo: false)
|
|
61
|
+
#
|
|
62
|
+
# @example Custom hash rotation
|
|
63
|
+
# otto.configure_ip_privacy(hash_rotation: 24.hours)
|
|
64
|
+
#
|
|
65
|
+
# @example Multi-server with Redis
|
|
66
|
+
# redis = Redis.new(url: ENV['REDIS_URL'])
|
|
67
|
+
# otto.configure_ip_privacy(redis: redis)
|
|
68
|
+
def configure_ip_privacy(octet_precision: nil, hash_rotation: nil, geo: nil, redis: nil)
|
|
69
|
+
ensure_not_frozen!
|
|
70
|
+
config = @security_config.ip_privacy_config
|
|
71
|
+
|
|
72
|
+
config.octet_precision = octet_precision if octet_precision
|
|
73
|
+
config.hash_rotation_period = hash_rotation if hash_rotation
|
|
74
|
+
config.geo_enabled = geo unless geo.nil?
|
|
75
|
+
config.instance_variable_set(:@redis, redis) if redis
|
|
76
|
+
|
|
77
|
+
# Validate configuration
|
|
78
|
+
config.validate!
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/otto/privacy.rb
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
|
-
# lib/otto/
|
|
1
|
+
# lib/otto/request.rb
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
require 'rack/request'
|
|
6
6
|
|
|
7
7
|
class Otto
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
# Otto's enhanced Rack::Request class with built-in helpers
|
|
9
|
+
#
|
|
10
|
+
# This class extends Rack::Request with Otto's framework helpers for
|
|
11
|
+
# HTTP request handling, privacy, security, and locale management.
|
|
12
|
+
# Projects can register additional helpers via Otto#register_request_helpers.
|
|
13
|
+
#
|
|
14
|
+
# @example Using Otto's request in route handlers
|
|
15
|
+
# def show(req, res)
|
|
16
|
+
# req.masked_ip # Privacy-safe masked IP
|
|
17
|
+
# req.geo_country # ISO country code
|
|
18
|
+
# req.check_locale! # Set locale for request
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @see Otto#register_request_helpers
|
|
22
|
+
class Request < Rack::Request
|
|
12
23
|
def user_agent
|
|
13
24
|
env['HTTP_USER_AGENT']
|
|
14
25
|
end
|
|
@@ -1,14 +1,27 @@
|
|
|
1
|
-
# lib/otto/
|
|
1
|
+
# lib/otto/response.rb
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
require 'rack/response'
|
|
6
6
|
|
|
7
7
|
class Otto
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
# Otto's enhanced Rack::Response class with built-in helpers
|
|
9
|
+
#
|
|
10
|
+
# This class extends Rack::Response with Otto's framework helpers for
|
|
11
|
+
# HTTP response handling, cookie management, CSP headers, and security.
|
|
12
|
+
# Projects can register additional helpers via Otto#register_response_helpers.
|
|
13
|
+
#
|
|
14
|
+
# @example Using Otto's response in route handlers
|
|
15
|
+
# def show(req, res)
|
|
16
|
+
# res.send_secure_cookie('session_id', token, 3600)
|
|
17
|
+
# res.send_csp_headers('text/html', nonce)
|
|
18
|
+
# res.no_cache!
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @see Otto#register_response_helpers
|
|
22
|
+
class Response < Rack::Response
|
|
23
|
+
# Reference to the request object (needed by some response helpers)
|
|
24
|
+
# @return [Otto::Request]
|
|
12
25
|
attr_accessor :request
|
|
13
26
|
|
|
14
27
|
def send_secure_cookie(name, value, ttl, opts = {})
|
|
@@ -171,7 +184,7 @@ class Otto
|
|
|
171
184
|
# # => "/myapp/admin/settings"
|
|
172
185
|
def app_path(*paths)
|
|
173
186
|
paths = paths.flatten.compact
|
|
174
|
-
paths.unshift(request.env['SCRIPT_NAME']) if request
|
|
187
|
+
paths.unshift(request.env['SCRIPT_NAME']) if request&.env&.[]('SCRIPT_NAME')
|
|
175
188
|
paths.join('/').gsub('//', '/')
|
|
176
189
|
end
|
|
177
190
|
end
|
|
@@ -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)
|
data/lib/otto/route.rb
CHANGED
|
@@ -100,10 +100,8 @@ class Otto
|
|
|
100
100
|
# @return [Array] Rack response array [status, headers, body]
|
|
101
101
|
def call(env, extra_params = {})
|
|
102
102
|
extra_params ||= {}
|
|
103
|
-
req =
|
|
104
|
-
res =
|
|
105
|
-
req.extend Otto::RequestHelpers
|
|
106
|
-
res.extend Otto::ResponseHelpers
|
|
103
|
+
req = otto.request_class.new(env)
|
|
104
|
+
res = otto.response_class.new
|
|
107
105
|
res.request = req
|
|
108
106
|
|
|
109
107
|
# Make security config available to response helpers
|
|
@@ -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 = otto_instance ? otto_instance.request_class.new(env) : Otto::Request.new(env)
|
|
27
|
+
res = otto_instance ? otto_instance.response_class.new : Otto::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,15 +46,84 @@ class Otto
|
|
|
32
46
|
@target_class ||= safe_const_get(route_definition.klass_name)
|
|
33
47
|
end
|
|
34
48
|
|
|
35
|
-
#
|
|
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
|
|
36
60
|
# @param req [Rack::Request] Request object
|
|
37
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
|
+
|
|
120
|
+
# Setup request and response with the same extensions and processing as Route#call
|
|
121
|
+
# @param req [Otto::Request] Request object
|
|
122
|
+
# @param res [Otto::Response] Response object
|
|
38
123
|
# @param env [Hash] Rack environment
|
|
39
124
|
# @param extra_params [Hash] Additional parameters
|
|
40
125
|
def setup_request_response(req, res, env, extra_params)
|
|
41
|
-
#
|
|
42
|
-
req.extend Otto::RequestHelpers
|
|
43
|
-
res.extend Otto::ResponseHelpers
|
|
126
|
+
# Set request reference (helpers are already included in class)
|
|
44
127
|
res.request = req
|
|
45
128
|
|
|
46
129
|
# Make security config available to response helpers
|
|
@@ -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
|