otto 1.6.0 → 2.0.0.pre2
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 +3 -2
- data/.github/workflows/claude-code-review.yml +53 -0
- data/.github/workflows/claude.yml +49 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +26 -344
- data/CHANGELOG.rst +131 -0
- data/CLAUDE.md +56 -0
- data/Gemfile +11 -4
- data/Gemfile.lock +38 -42
- data/README.md +2 -0
- data/bin/rspec +4 -4
- data/changelog.d/README.md +120 -0
- data/changelog.d/scriv.ini +5 -0
- data/docs/.gitignore +2 -0
- data/docs/migrating/v2.0.0-pre1.md +276 -0
- data/docs/migrating/v2.0.0-pre2.md +345 -0
- data/examples/.gitignore +1 -0
- data/examples/advanced_routes/README.md +33 -0
- data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
- data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
- data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
- data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
- data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
- data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
- data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
- data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
- data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
- data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
- data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
- data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
- data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
- data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
- data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
- data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
- data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
- data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
- data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
- data/examples/advanced_routes/app.rb +33 -0
- data/examples/advanced_routes/config.rb +23 -0
- data/examples/advanced_routes/config.ru +7 -0
- data/examples/advanced_routes/puma.rb +20 -0
- data/examples/advanced_routes/routes +167 -0
- data/examples/advanced_routes/run.rb +39 -0
- data/examples/advanced_routes/test.rb +58 -0
- data/examples/authentication_strategies/README.md +32 -0
- data/examples/authentication_strategies/app/auth.rb +68 -0
- data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
- data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
- data/examples/authentication_strategies/config.ru +24 -0
- data/examples/authentication_strategies/routes +37 -0
- data/examples/basic/README.md +29 -0
- data/examples/basic/app.rb +7 -35
- data/examples/basic/routes +0 -9
- data/examples/mcp_demo/README.md +87 -0
- data/examples/mcp_demo/app.rb +29 -34
- data/examples/mcp_demo/config.ru +9 -60
- data/examples/security_features/README.md +46 -0
- data/examples/security_features/app.rb +23 -24
- data/examples/security_features/config.ru +8 -10
- data/lib/otto/core/configuration.rb +167 -0
- data/lib/otto/core/error_handler.rb +86 -0
- data/lib/otto/core/file_safety.rb +61 -0
- data/lib/otto/core/middleware_stack.rb +237 -0
- data/lib/otto/core/router.rb +184 -0
- data/lib/otto/core/uri_generator.rb +44 -0
- data/lib/otto/design_system.rb +7 -5
- data/lib/otto/env_keys.rb +114 -0
- data/lib/otto/helpers/base.rb +5 -21
- data/lib/otto/helpers/request.rb +10 -8
- data/lib/otto/helpers/response.rb +27 -4
- data/lib/otto/helpers/validation.rb +9 -7
- data/lib/otto/mcp/auth/token.rb +10 -9
- data/lib/otto/mcp/protocol.rb +24 -27
- data/lib/otto/mcp/rate_limiting.rb +8 -3
- data/lib/otto/mcp/registry.rb +7 -2
- data/lib/otto/mcp/route_parser.rb +10 -15
- data/lib/otto/mcp/{validation.rb → schema_validation.rb} +16 -11
- data/lib/otto/mcp/server.rb +45 -22
- data/lib/otto/response_handlers/auto.rb +39 -0
- data/lib/otto/response_handlers/base.rb +16 -0
- data/lib/otto/response_handlers/default.rb +16 -0
- data/lib/otto/response_handlers/factory.rb +39 -0
- data/lib/otto/response_handlers/json.rb +34 -0
- data/lib/otto/response_handlers/redirect.rb +25 -0
- data/lib/otto/response_handlers/view.rb +24 -0
- data/lib/otto/response_handlers.rb +9 -135
- data/lib/otto/route.rb +51 -55
- data/lib/otto/route_definition.rb +15 -18
- data/lib/otto/route_handlers/base.rb +121 -0
- data/lib/otto/route_handlers/class_method.rb +89 -0
- data/lib/otto/route_handlers/factory.rb +42 -0
- data/lib/otto/route_handlers/instance_method.rb +69 -0
- data/lib/otto/route_handlers/lambda.rb +59 -0
- data/lib/otto/route_handlers/logic_class.rb +93 -0
- data/lib/otto/route_handlers.rb +10 -405
- data/lib/otto/security/authentication/auth_strategy.rb +44 -0
- data/lib/otto/security/authentication/authentication_middleware.rb +140 -0
- data/lib/otto/security/authentication/failure_result.rb +44 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +149 -0
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +19 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
- data/lib/otto/security/authentication/strategy_result.rb +337 -0
- data/lib/otto/security/authentication.rb +28 -282
- data/lib/otto/security/config.rb +14 -23
- data/lib/otto/security/configurator.rb +219 -0
- data/lib/otto/security/csrf.rb +8 -143
- data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +54 -0
- data/lib/otto/security/middleware/validation_middleware.rb +252 -0
- data/lib/otto/security/rate_limiter.rb +86 -0
- data/lib/otto/security/rate_limiting.rb +10 -105
- data/lib/otto/security/validator.rb +8 -253
- data/lib/otto/static.rb +3 -0
- data/lib/otto/utils.rb +14 -0
- data/lib/otto/version.rb +3 -1
- data/lib/otto.rb +141 -498
- data/otto.gemspec +4 -2
- metadata +99 -18
- data/examples/dynamic_pages/app.rb +0 -115
- data/examples/dynamic_pages/config.ru +0 -30
- data/examples/dynamic_pages/routes +0 -21
- data/examples/helpers_demo/app.rb +0 -244
- data/examples/helpers_demo/config.ru +0 -26
- data/examples/helpers_demo/routes +0 -7
- data/lib/concurrent_cache_store.rb +0 -68
data/lib/otto/mcp/server.rb
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/mcp/server.rb
|
|
4
|
+
|
|
1
5
|
require_relative 'protocol'
|
|
2
6
|
require_relative 'registry'
|
|
3
7
|
require_relative 'route_parser'
|
|
4
8
|
require_relative 'auth/token'
|
|
5
|
-
require_relative '
|
|
9
|
+
require_relative 'schema_validation'
|
|
6
10
|
require_relative 'rate_limiting'
|
|
7
11
|
|
|
8
12
|
class Otto
|
|
9
13
|
module MCP
|
|
14
|
+
# MCP server implementation providing Model Context Protocol endpoints
|
|
10
15
|
class Server
|
|
11
16
|
attr_reader :protocol, :otto_instance
|
|
12
17
|
|
|
@@ -48,30 +53,43 @@ class Otto
|
|
|
48
53
|
private
|
|
49
54
|
|
|
50
55
|
def configure_middleware(_options)
|
|
51
|
-
# Configure middleware in security-optimal order:
|
|
52
|
-
# 1. Rate limiting (reject excessive requests early)
|
|
53
|
-
# 2. Authentication (validate credentials before parsing)
|
|
54
|
-
# 3. Validation (expensive JSON schema validation last)
|
|
56
|
+
# Configure middleware in security-optimal order using explicit positioning:
|
|
57
|
+
# 1. Rate limiting (reject excessive requests early) - position: :first
|
|
58
|
+
# 2. Authentication (validate credentials before parsing) - default append
|
|
59
|
+
# 3. Validation (expensive JSON schema validation last) - position: :last
|
|
55
60
|
|
|
56
|
-
|
|
61
|
+
middleware = @otto_instance.instance_variable_get(:@middleware)
|
|
62
|
+
|
|
63
|
+
# Configure rate limiting first (explicit position for clarity)
|
|
57
64
|
if @enable_rate_limiting
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
middleware.add_with_position(
|
|
66
|
+
Otto::MCP::RateLimitMiddleware,
|
|
67
|
+
@otto_instance.security_config,
|
|
68
|
+
position: :first
|
|
69
|
+
)
|
|
70
|
+
Otto.logger.debug '[MCP] Rate limiting enabled (position: first)' if Otto.debug
|
|
60
71
|
end
|
|
61
72
|
|
|
62
|
-
# Configure authentication second
|
|
73
|
+
# Configure authentication second (default append order)
|
|
63
74
|
if @auth_tokens.any?
|
|
64
|
-
@auth
|
|
75
|
+
@auth = Otto::MCP::Auth::TokenAuth.new(@auth_tokens)
|
|
65
76
|
@otto_instance.security_config.mcp_auth = @auth
|
|
66
77
|
@otto_instance.use Otto::MCP::Auth::TokenMiddleware
|
|
67
78
|
Otto.logger.debug '[MCP] Token authentication enabled' if Otto.debug
|
|
68
79
|
end
|
|
69
80
|
|
|
70
|
-
# Configure validation last (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
# Configure validation last (explicit position for clarity)
|
|
82
|
+
return unless @enable_validation
|
|
83
|
+
|
|
84
|
+
middleware.add_with_position(
|
|
85
|
+
Otto::MCP::SchemaValidationMiddleware,
|
|
86
|
+
position: :last
|
|
87
|
+
)
|
|
88
|
+
Otto.logger.debug '[MCP] Schema validation enabled (position: last)' if Otto.debug
|
|
89
|
+
|
|
90
|
+
# Validate middleware order (should pass with explicit positioning)
|
|
91
|
+
warnings = middleware.validate_mcp_middleware_order
|
|
92
|
+
warnings.each { |warning| Otto.logger.warn warning }
|
|
75
93
|
end
|
|
76
94
|
|
|
77
95
|
def add_mcp_endpoint_route
|
|
@@ -106,11 +124,16 @@ class Otto
|
|
|
106
124
|
|
|
107
125
|
# Create resource handler
|
|
108
126
|
handler = lambda do
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
127
|
+
klass = Object.const_get(klass_name)
|
|
128
|
+
method = klass.method(method_name)
|
|
129
|
+
if method.arity != 0
|
|
130
|
+
raise ArgumentError, "Handler #{klass_name}.#{method_name} must be a zero-arity method for resource #{uri}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
klass.public_send(method_name)
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
Otto.logger.error "[MCP] Resource handler error for #{uri}: #{e.message}"
|
|
136
|
+
raise
|
|
114
137
|
end
|
|
115
138
|
|
|
116
139
|
# Register with protocol registry
|
|
@@ -119,7 +142,7 @@ class Otto
|
|
|
119
142
|
extract_name_from_uri(uri),
|
|
120
143
|
"Resource: #{uri}",
|
|
121
144
|
'text/plain',
|
|
122
|
-
handler
|
|
145
|
+
handler
|
|
123
146
|
)
|
|
124
147
|
|
|
125
148
|
Otto.logger.debug "[MCP] Registered resource: #{uri} -> #{handler_def}" if Otto.debug
|
|
@@ -146,7 +169,7 @@ class Otto
|
|
|
146
169
|
name,
|
|
147
170
|
"Tool: #{name}",
|
|
148
171
|
input_schema,
|
|
149
|
-
"#{klass_name}.#{method_name}"
|
|
172
|
+
"#{klass_name}.#{method_name}"
|
|
150
173
|
)
|
|
151
174
|
|
|
152
175
|
Otto.logger.debug "[MCP] Registered tool: #{name} -> #{handler_def}" if Otto.debug
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative 'json'
|
|
5
|
+
require_relative 'redirect'
|
|
6
|
+
require_relative 'view'
|
|
7
|
+
require_relative 'default'
|
|
8
|
+
|
|
9
|
+
class Otto
|
|
10
|
+
module ResponseHandlers
|
|
11
|
+
# Auto-detection handler that chooses appropriate handler based on context
|
|
12
|
+
class AutoHandler < BaseHandler
|
|
13
|
+
def self.handle(result, response, context = {})
|
|
14
|
+
# Auto-detect based on result type and request context
|
|
15
|
+
handler_class = detect_handler_type(result, response, context)
|
|
16
|
+
handler_class.handle(result, response, context)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.detect_handler_type(result, response, context)
|
|
20
|
+
# Check if response type was already set by the handler
|
|
21
|
+
content_type = response['Content-Type']
|
|
22
|
+
|
|
23
|
+
if content_type&.include?('application/json')
|
|
24
|
+
JSONHandler
|
|
25
|
+
elsif (context[:logic_instance]&.respond_to?(:redirect_path) && context[:logic_instance].redirect_path) ||
|
|
26
|
+
(result.is_a?(String) && result.match?(%r{^/}))
|
|
27
|
+
# Logic instance has redirect path or result is a string path
|
|
28
|
+
RedirectHandler
|
|
29
|
+
elsif result.is_a?(Hash)
|
|
30
|
+
JSONHandler
|
|
31
|
+
elsif context[:logic_instance]&.respond_to?(:view)
|
|
32
|
+
ViewHandler
|
|
33
|
+
else
|
|
34
|
+
DefaultHandler
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
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
|
+
def self.ensure_status_set(response, default_status = 200)
|
|
12
|
+
response.status = default_status unless response.status && response.status != 0
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module ResponseHandlers
|
|
7
|
+
# Default handler that preserves existing Otto behavior
|
|
8
|
+
class DefaultHandler < BaseHandler
|
|
9
|
+
def self.handle(_result, response, _context = {})
|
|
10
|
+
# Otto's default behavior - let the route handler manage the response
|
|
11
|
+
# This handler does nothing, preserving existing behavior
|
|
12
|
+
ensure_status_set(response, 200)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'json'
|
|
4
|
+
require_relative 'redirect'
|
|
5
|
+
require_relative 'view'
|
|
6
|
+
require_relative 'auto'
|
|
7
|
+
require_relative 'default'
|
|
8
|
+
|
|
9
|
+
class Otto
|
|
10
|
+
module ResponseHandlers
|
|
11
|
+
# Factory for creating response handlers
|
|
12
|
+
class HandlerFactory
|
|
13
|
+
# Map of response type names to handler classes
|
|
14
|
+
HANDLER_MAP = {
|
|
15
|
+
'json' => JSONHandler,
|
|
16
|
+
'redirect' => RedirectHandler,
|
|
17
|
+
'view' => ViewHandler,
|
|
18
|
+
'auto' => AutoHandler,
|
|
19
|
+
'default' => DefaultHandler,
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def self.create_handler(response_type)
|
|
23
|
+
handler_class = HANDLER_MAP[response_type.to_s.downcase]
|
|
24
|
+
|
|
25
|
+
unless handler_class
|
|
26
|
+
Otto.logger.warn "Unknown response type: #{response_type}, falling back to default" if Otto.debug
|
|
27
|
+
handler_class = DefaultHandler
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
handler_class
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.handle_response(result, response, response_type, context = {})
|
|
34
|
+
handler = create_handler(response_type)
|
|
35
|
+
handler.handle(result, response, context)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module ResponseHandlers
|
|
7
|
+
# Handler for JSON responses
|
|
8
|
+
class JSONHandler < BaseHandler
|
|
9
|
+
def self.handle(result, response, context = {})
|
|
10
|
+
# If a redirect has already been set, don't override with JSON
|
|
11
|
+
# This allows controllers to conditionally redirect based on Accept header
|
|
12
|
+
if response.status&.between?(300, 399) && response['Location']
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
response['Content-Type'] = 'application/json'
|
|
17
|
+
|
|
18
|
+
# Determine the data to serialize
|
|
19
|
+
data = if context[:logic_instance]&.respond_to?(:response_data)
|
|
20
|
+
context[:logic_instance].response_data
|
|
21
|
+
elsif result.is_a?(Hash)
|
|
22
|
+
result
|
|
23
|
+
elsif result.nil?
|
|
24
|
+
{ success: true }
|
|
25
|
+
else
|
|
26
|
+
{ success: true, data: result }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
response.body = [JSON.generate(data)]
|
|
30
|
+
ensure_status_set(response, context[:status_code] || 200)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module ResponseHandlers
|
|
7
|
+
# Handler for redirect responses
|
|
8
|
+
class RedirectHandler < BaseHandler
|
|
9
|
+
def self.handle(result, response, context = {})
|
|
10
|
+
# Determine redirect path
|
|
11
|
+
path = if context[:redirect_path]
|
|
12
|
+
context[:redirect_path]
|
|
13
|
+
elsif context[:logic_instance]&.respond_to?(:redirect_path)
|
|
14
|
+
context[:logic_instance].redirect_path
|
|
15
|
+
elsif result.is_a?(String)
|
|
16
|
+
result
|
|
17
|
+
else
|
|
18
|
+
'/'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
response.redirect(path)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module ResponseHandlers
|
|
7
|
+
# Handler for view/template responses
|
|
8
|
+
class ViewHandler < BaseHandler
|
|
9
|
+
def self.handle(result, response, context = {})
|
|
10
|
+
if context[:logic_instance]&.respond_to?(:view)
|
|
11
|
+
response.body = [context[:logic_instance].view.render]
|
|
12
|
+
response['Content-Type'] = 'text/html' unless response['Content-Type']
|
|
13
|
+
elsif result.respond_to?(:to_s)
|
|
14
|
+
response.body = [result.to_s]
|
|
15
|
+
response['Content-Type'] = 'text/html' unless response['Content-Type']
|
|
16
|
+
else
|
|
17
|
+
response.body = ['']
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
ensure_status_set(response, context[:status_code] || 200)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -1,141 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
# lib/otto/response_handlers.rb
|
|
2
4
|
|
|
3
5
|
class Otto
|
|
4
6
|
module ResponseHandlers
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
7
|
+
require_relative 'response_handlers/base'
|
|
8
|
+
require_relative 'response_handlers/json'
|
|
9
|
+
require_relative 'response_handlers/redirect'
|
|
10
|
+
require_relative 'response_handlers/view'
|
|
11
|
+
require_relative 'response_handlers/default'
|
|
12
|
+
require_relative 'response_handlers/auto'
|
|
13
|
+
require_relative 'response_handlers/factory'
|
|
140
14
|
end
|
|
141
15
|
end
|
data/lib/otto/route.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
# lib/otto/route.rb
|
|
2
4
|
|
|
3
5
|
class Otto
|
|
@@ -20,6 +22,7 @@ class Otto
|
|
|
20
22
|
#
|
|
21
23
|
#
|
|
22
24
|
class Route
|
|
25
|
+
# Class methods for Route providing Otto instance access
|
|
23
26
|
module ClassMethods
|
|
24
27
|
attr_accessor :otto
|
|
25
28
|
end
|
|
@@ -42,10 +45,10 @@ class Otto
|
|
|
42
45
|
# "V2::Logic::AuthSession auth=authenticated response=redirect" (enhanced)
|
|
43
46
|
# @raise [ArgumentError] if definition format is invalid or class name is unsafe
|
|
44
47
|
def initialize(verb, path, definition)
|
|
45
|
-
|
|
48
|
+
pattern, keys = *compile(path)
|
|
46
49
|
|
|
47
50
|
# Create immutable route definition
|
|
48
|
-
@route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern:
|
|
51
|
+
@route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern: pattern, keys: keys)
|
|
49
52
|
|
|
50
53
|
# Resolve the class
|
|
51
54
|
@klass = safe_const_get(@route_definition.klass_name)
|
|
@@ -84,53 +87,6 @@ class Otto
|
|
|
84
87
|
@route_definition.options
|
|
85
88
|
end
|
|
86
89
|
|
|
87
|
-
private
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
# Safely resolve a class name using Object.const_get with security validations
|
|
91
|
-
# This replaces the previous eval() usage to prevent code injection attacks.
|
|
92
|
-
#
|
|
93
|
-
# Security features:
|
|
94
|
-
# - Validates class name format (must start with capital letter)
|
|
95
|
-
# - Prevents access to dangerous system classes
|
|
96
|
-
# - Blocks relative class references (starting with ::)
|
|
97
|
-
# - Provides clear error messages for debugging
|
|
98
|
-
#
|
|
99
|
-
# @param class_name [String] The class name to resolve
|
|
100
|
-
# @return [Class] The resolved class
|
|
101
|
-
# @raise [ArgumentError] if class name is invalid, forbidden, or not found
|
|
102
|
-
def safe_const_get(class_name)
|
|
103
|
-
# Validate class name format
|
|
104
|
-
unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
|
|
105
|
-
raise ArgumentError, "Invalid class name format: #{class_name}"
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# Prevent dangerous class names
|
|
109
|
-
forbidden_classes = %w[
|
|
110
|
-
Kernel Module Class Object BasicObject
|
|
111
|
-
File Dir IO Process System
|
|
112
|
-
Binding Proc Method UnboundMethod
|
|
113
|
-
Thread ThreadGroup Fiber
|
|
114
|
-
ObjectSpace GC
|
|
115
|
-
]
|
|
116
|
-
|
|
117
|
-
if forbidden_classes.include?(class_name) || class_name.start_with?('::')
|
|
118
|
-
raise ArgumentError, "Forbidden class name: #{class_name}"
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
begin
|
|
122
|
-
Object.const_get(class_name)
|
|
123
|
-
rescue NameError => ex
|
|
124
|
-
raise ArgumentError, "Class not found: #{class_name} - #{ex.message}"
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
public
|
|
129
|
-
|
|
130
|
-
def pattern_regexp
|
|
131
|
-
Regexp.new(@path.gsub('/*', '/.+'))
|
|
132
|
-
end
|
|
133
|
-
|
|
134
90
|
# Execute the route by calling the associated class method
|
|
135
91
|
#
|
|
136
92
|
# This method handles the complete request/response cycle with built-in security:
|
|
@@ -148,12 +104,10 @@ class Otto
|
|
|
148
104
|
res = Rack::Response.new
|
|
149
105
|
req.extend Otto::RequestHelpers
|
|
150
106
|
res.extend Otto::ResponseHelpers
|
|
151
|
-
res.request
|
|
107
|
+
res.request = req
|
|
152
108
|
|
|
153
109
|
# 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
|
|
110
|
+
env['otto.security_config'] = otto.security_config if otto.respond_to?(:security_config) && otto.security_config
|
|
157
111
|
|
|
158
112
|
# NEW: Make route definition and options available to middleware and handlers
|
|
159
113
|
env['otto.route_definition'] = @route_definition
|
|
@@ -185,7 +139,7 @@ class Otto
|
|
|
185
139
|
# This replaces the hardcoded execution pattern with a factory approach
|
|
186
140
|
if otto&.route_handler_factory
|
|
187
141
|
handler = otto.route_handler_factory.create_handler(@route_definition, otto)
|
|
188
|
-
|
|
142
|
+
handler.call(env, extra_params)
|
|
189
143
|
else
|
|
190
144
|
# Fallback to legacy behavior for backward compatibility
|
|
191
145
|
inst = nil
|
|
@@ -205,7 +159,7 @@ class Otto
|
|
|
205
159
|
context = {
|
|
206
160
|
logic_instance: (kind == :instance ? inst : nil),
|
|
207
161
|
status_code: nil,
|
|
208
|
-
redirect_path: nil
|
|
162
|
+
redirect_path: nil,
|
|
209
163
|
}
|
|
210
164
|
|
|
211
165
|
Otto::ResponseHandlers::HandlerFactory.handle_response(result, res, response_type, context)
|
|
@@ -218,6 +172,48 @@ class Otto
|
|
|
218
172
|
|
|
219
173
|
private
|
|
220
174
|
|
|
175
|
+
# Safely resolve a class name using Object.const_get with security validations
|
|
176
|
+
# This replaces the previous eval() usage to prevent code injection attacks.
|
|
177
|
+
#
|
|
178
|
+
# Security features:
|
|
179
|
+
# - Validates class name format (must start with capital letter)
|
|
180
|
+
# - Prevents access to dangerous system classes
|
|
181
|
+
# - Blocks relative class references (starting with ::)
|
|
182
|
+
# - Provides clear error messages for debugging
|
|
183
|
+
#
|
|
184
|
+
# @param class_name [String] The class name to resolve
|
|
185
|
+
# @return [Class] The resolved class
|
|
186
|
+
# @raise [ArgumentError] if class name is invalid, forbidden, or not found
|
|
187
|
+
def safe_const_get(class_name)
|
|
188
|
+
# Validate class name format
|
|
189
|
+
unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
|
|
190
|
+
raise ArgumentError, "Invalid class name format: #{class_name}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Remove any leading :: then add exactly one
|
|
194
|
+
fq_class_name = "::#{class_name.sub(/^::+/, '')}"
|
|
195
|
+
|
|
196
|
+
# Prevent dangerous class names
|
|
197
|
+
forbidden_classes = %w[
|
|
198
|
+
Kernel Module Class Object BasicObject
|
|
199
|
+
File Dir IO Process System
|
|
200
|
+
Binding Proc Method UnboundMethod
|
|
201
|
+
Thread ThreadGroup Fiber
|
|
202
|
+
ObjectSpace GC
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
if forbidden_classes.include?(class_name) || class_name.start_with?('::')
|
|
206
|
+
raise ArgumentError, "Forbidden class name: #{class_name}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
begin
|
|
210
|
+
# Always guarantee exactly two leading colons
|
|
211
|
+
Object.const_get(fq_class_name)
|
|
212
|
+
rescue NameError => e
|
|
213
|
+
raise ArgumentError, "Class not found: #{fq_class_name} - #{e.message}"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
221
217
|
def compile(path)
|
|
222
218
|
keys = []
|
|
223
219
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
# lib/otto/route_definition.rb
|
|
2
4
|
|
|
3
5
|
class Otto
|
|
@@ -161,24 +163,19 @@ class Otto
|
|
|
161
163
|
# @param target [String] The target definition (e.g., "TestApp.index")
|
|
162
164
|
# @return [Hash] Hash with :klass_name, :method_name, and :kind
|
|
163
165
|
def parse_target(target)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
166
|
+
case target
|
|
167
|
+
when /^(.+)\.(.+)$/
|
|
168
|
+
# Class.method - call class method directly
|
|
169
|
+
{ klass_name: $1, method_name: $2, kind: :class }
|
|
170
|
+
|
|
171
|
+
when /^(.+)#(.+)$/
|
|
172
|
+
# Class#method - instantiate then call instance method
|
|
173
|
+
{ klass_name: $1, method_name: $2, kind: :instance }
|
|
174
|
+
|
|
175
|
+
when /^[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*$/
|
|
176
|
+
# Bare class name - instantiate the class
|
|
177
|
+
{ klass_name: target, method_name: target.split('::').last, kind: :logic }
|
|
178
|
+
|
|
182
179
|
else
|
|
183
180
|
raise ArgumentError, "Invalid target format: #{target}"
|
|
184
181
|
end
|