otto 1.6.0 → 2.0.0.pre1
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 +53 -0
- data/.github/workflows/claude.yml +49 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +24 -345
- data/CHANGELOG.rst +83 -0
- data/CLAUDE.md +56 -0
- data/Gemfile +10 -3
- data/Gemfile.lock +23 -28
- data/README.md +2 -0
- data/bin/rspec +4 -4
- data/changelog.d/20250911_235619_delano_next.rst +28 -0
- data/changelog.d/20250912_123055_delano_remove_ostruct.rst +21 -0
- data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +21 -0
- data/changelog.d/README.md +120 -0
- data/changelog.d/scriv.ini +5 -0
- data/docs/.gitignore +1 -0
- data/docs/migrating/v2.0.0-pre1.md +276 -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 +157 -0
- data/lib/otto/core/router.rb +183 -0
- data/lib/otto/core/uri_generator.rb +44 -0
- data/lib/otto/design_system.rb +7 -5
- data/lib/otto/helpers/base.rb +3 -0
- data/lib/otto/helpers/request.rb +10 -8
- data/lib/otto/helpers/response.rb +5 -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/server.rb +21 -11
- data/lib/otto/mcp/validation.rb +14 -10
- 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 +28 -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 +9 -9
- 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 +29 -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 +123 -0
- data/lib/otto/security/authentication/failure_result.rb +36 -0
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
- data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -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 +223 -0
- data/lib/otto/security/authentication.rb +28 -282
- data/lib/otto/security/config.rb +14 -12
- 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 +38 -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 +142 -498
- data/otto.gemspec +2 -2
- metadata +89 -28
- 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
@@ -1,21 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto/mcp/route_parser.rb
|
4
|
+
|
1
5
|
class Otto
|
2
6
|
module MCP
|
7
|
+
# Parser for MCP route definitions and resource URIs
|
3
8
|
class RouteParser
|
4
9
|
def self.parse_mcp_route(_verb, _path, definition)
|
5
10
|
# MCP route format: MCP resource_uri HandlerClass.method_name
|
6
11
|
# Note: The path parameter is ignored for MCP routes - resource_uri comes from definition
|
7
12
|
parts = definition.split(/\s+/, 3)
|
8
13
|
|
9
|
-
if parts[0] != 'MCP'
|
10
|
-
raise ArgumentError, "Expected MCP keyword, got: #{parts[0]}"
|
11
|
-
end
|
14
|
+
raise ArgumentError, "Expected MCP keyword, got: #{parts[0]}" if parts[0] != 'MCP'
|
12
15
|
|
13
16
|
resource_uri = parts[1]
|
14
17
|
handler_definition = parts[2]
|
15
18
|
|
16
|
-
unless resource_uri && handler_definition
|
17
|
-
raise ArgumentError, "Invalid MCP route format: #{definition}"
|
18
|
-
end
|
19
|
+
raise ArgumentError, "Invalid MCP route format: #{definition}" unless resource_uri && handler_definition
|
19
20
|
|
20
21
|
# Clean up URI - remove leading slash if present since MCP URIs are relative
|
21
22
|
resource_uri = resource_uri.sub(%r{^/}, '')
|
@@ -33,16 +34,12 @@ class Otto
|
|
33
34
|
# Note: The path parameter is ignored for TOOL routes - tool_name comes from definition
|
34
35
|
parts = definition.split(/\s+/, 3)
|
35
36
|
|
36
|
-
if parts[0] != 'TOOL'
|
37
|
-
raise ArgumentError, "Expected TOOL keyword, got: #{parts[0]}"
|
38
|
-
end
|
37
|
+
raise ArgumentError, "Expected TOOL keyword, got: #{parts[0]}" if parts[0] != 'TOOL'
|
39
38
|
|
40
39
|
tool_name = parts[1]
|
41
40
|
handler_definition = parts[2]
|
42
41
|
|
43
|
-
unless tool_name && handler_definition
|
44
|
-
raise ArgumentError, "Invalid TOOL route format: #{definition}"
|
45
|
-
end
|
42
|
+
raise ArgumentError, "Invalid TOOL route format: #{definition}" unless tool_name && handler_definition
|
46
43
|
|
47
44
|
# Clean up tool name - remove leading slash if present
|
48
45
|
tool_name = tool_name.sub(%r{^/}, '')
|
@@ -70,9 +67,7 @@ class Otto
|
|
70
67
|
# First part is the handler class.method
|
71
68
|
parts[1..-1]&.each do |part|
|
72
69
|
key, value = part.split('=', 2)
|
73
|
-
if key && value
|
74
|
-
options[key.to_sym] = value
|
75
|
-
end
|
70
|
+
options[key.to_sym] = value if key && value
|
76
71
|
end
|
77
72
|
|
78
73
|
options
|
data/lib/otto/mcp/server.rb
CHANGED
@@ -1,3 +1,7 @@
|
|
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'
|
@@ -7,6 +11,7 @@ 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
|
|
@@ -68,10 +73,10 @@ class Otto
|
|
68
73
|
end
|
69
74
|
|
70
75
|
# Configure validation last (most expensive)
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
76
|
+
return unless @enable_validation
|
77
|
+
|
78
|
+
@otto_instance.use Otto::MCP::ValidationMiddleware
|
79
|
+
Otto.logger.debug '[MCP] Request validation enabled' if Otto.debug
|
75
80
|
end
|
76
81
|
|
77
82
|
def add_mcp_endpoint_route
|
@@ -106,11 +111,16 @@ class Otto
|
|
106
111
|
|
107
112
|
# Create resource handler
|
108
113
|
handler = lambda do
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
+
klass = Object.const_get(klass_name)
|
115
|
+
method = klass.method(method_name)
|
116
|
+
if method.arity != 0
|
117
|
+
raise ArgumentError, "Handler #{klass_name}.#{method_name} must be a zero-arity method for resource #{uri}"
|
118
|
+
end
|
119
|
+
|
120
|
+
klass.public_send(method_name)
|
121
|
+
rescue StandardError => e
|
122
|
+
Otto.logger.error "[MCP] Resource handler error for #{uri}: #{e.message}"
|
123
|
+
raise
|
114
124
|
end
|
115
125
|
|
116
126
|
# Register with protocol registry
|
@@ -119,7 +129,7 @@ class Otto
|
|
119
129
|
extract_name_from_uri(uri),
|
120
130
|
"Resource: #{uri}",
|
121
131
|
'text/plain',
|
122
|
-
handler
|
132
|
+
handler
|
123
133
|
)
|
124
134
|
|
125
135
|
Otto.logger.debug "[MCP] Registered resource: #{uri} -> #{handler_def}" if Otto.debug
|
@@ -146,7 +156,7 @@ class Otto
|
|
146
156
|
name,
|
147
157
|
"Tool: #{name}",
|
148
158
|
input_schema,
|
149
|
-
"#{klass_name}.#{method_name}"
|
159
|
+
"#{klass_name}.#{method_name}"
|
150
160
|
)
|
151
161
|
|
152
162
|
Otto.logger.debug "[MCP] Registered tool: #{name} -> #{handler_def}" if Otto.debug
|
data/lib/otto/mcp/validation.rb
CHANGED
@@ -1,3 +1,7 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto/mcp/validation.rb
|
4
|
+
|
1
5
|
require 'json'
|
2
6
|
|
3
7
|
begin
|
@@ -10,6 +14,7 @@ class Otto
|
|
10
14
|
module MCP
|
11
15
|
class ValidationError < StandardError; end
|
12
16
|
|
17
|
+
# JSON Schema validator for MCP protocol requests
|
13
18
|
class Validator
|
14
19
|
def initialize
|
15
20
|
@schemas = {}
|
@@ -48,7 +53,7 @@ class Otto
|
|
48
53
|
|
49
54
|
def mcp_request_schema
|
50
55
|
@schemas[:mcp_request] ||= JSONSchemer.schema({
|
51
|
-
|
56
|
+
type: 'object',
|
52
57
|
required: %w[jsonrpc method id],
|
53
58
|
properties: {
|
54
59
|
jsonrpc: { const: '2.0' },
|
@@ -57,11 +62,11 @@ class Otto
|
|
57
62
|
params: { type: 'object' },
|
58
63
|
},
|
59
64
|
additionalProperties: false,
|
60
|
-
|
61
|
-
)
|
65
|
+
})
|
62
66
|
end
|
63
67
|
end
|
64
68
|
|
69
|
+
# Middleware for validating MCP protocol requests using JSON schema
|
65
70
|
class ValidationMiddleware
|
66
71
|
def initialize(app, _security_config = nil)
|
67
72
|
@app = app
|
@@ -82,10 +87,10 @@ class Otto
|
|
82
87
|
|
83
88
|
# Reset body for downstream middleware
|
84
89
|
request.body.rewind if request.body.respond_to?(:rewind)
|
85
|
-
rescue JSON::ParserError =>
|
86
|
-
return validation_error_response(nil, "Invalid JSON: #{
|
87
|
-
rescue ValidationError =>
|
88
|
-
return validation_error_response(data&.dig('id'),
|
90
|
+
rescue JSON::ParserError => e
|
91
|
+
return validation_error_response(nil, "Invalid JSON: #{e.message}")
|
92
|
+
rescue ValidationError => e
|
93
|
+
return validation_error_response(data&.dig('id'), e.message)
|
89
94
|
end
|
90
95
|
end
|
91
96
|
|
@@ -102,15 +107,14 @@ class Otto
|
|
102
107
|
|
103
108
|
def validation_error_response(id, message)
|
104
109
|
body = JSON.generate({
|
105
|
-
|
110
|
+
jsonrpc: '2.0',
|
106
111
|
id: id,
|
107
112
|
error: {
|
108
113
|
code: -32_600,
|
109
114
|
message: 'Invalid Request',
|
110
115
|
data: message,
|
111
116
|
},
|
112
|
-
|
113
|
-
)
|
117
|
+
})
|
114
118
|
|
115
119
|
[400, { 'content-type' => 'application/json' }, [body]]
|
116
120
|
end
|
@@ -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,28 @@
|
|
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
|
+
response['Content-Type'] = 'application/json'
|
11
|
+
|
12
|
+
# Determine the data to serialize
|
13
|
+
data = if context[:logic_instance]&.respond_to?(:response_data)
|
14
|
+
context[:logic_instance].response_data
|
15
|
+
elsif result.is_a?(Hash)
|
16
|
+
result
|
17
|
+
elsif result.nil?
|
18
|
+
{ success: true }
|
19
|
+
else
|
20
|
+
{ success: true, data: result }
|
21
|
+
end
|
22
|
+
|
23
|
+
response.body = [JSON.generate(data)]
|
24
|
+
ensure_status_set(response, context[:status_code] || 200)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
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
|
@@ -86,7 +89,6 @@ class Otto
|
|
86
89
|
|
87
90
|
private
|
88
91
|
|
89
|
-
|
90
92
|
# Safely resolve a class name using Object.const_get with security validations
|
91
93
|
# This replaces the previous eval() usage to prevent code injection attacks.
|
92
94
|
#
|
@@ -120,8 +122,8 @@ class Otto
|
|
120
122
|
|
121
123
|
begin
|
122
124
|
Object.const_get(class_name)
|
123
|
-
rescue NameError =>
|
124
|
-
raise ArgumentError, "Class not found: #{class_name} - #{
|
125
|
+
rescue NameError => e
|
126
|
+
raise ArgumentError, "Class not found: #{class_name} - #{e.message}"
|
125
127
|
end
|
126
128
|
end
|
127
129
|
|
@@ -148,12 +150,10 @@ class Otto
|
|
148
150
|
res = Rack::Response.new
|
149
151
|
req.extend Otto::RequestHelpers
|
150
152
|
res.extend Otto::ResponseHelpers
|
151
|
-
res.request
|
153
|
+
res.request = req
|
152
154
|
|
153
155
|
# 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
|
156
|
+
env['otto.security_config'] = otto.security_config if otto.respond_to?(:security_config) && otto.security_config
|
157
157
|
|
158
158
|
# NEW: Make route definition and options available to middleware and handlers
|
159
159
|
env['otto.route_definition'] = @route_definition
|
@@ -185,7 +185,7 @@ class Otto
|
|
185
185
|
# This replaces the hardcoded execution pattern with a factory approach
|
186
186
|
if otto&.route_handler_factory
|
187
187
|
handler = otto.route_handler_factory.create_handler(@route_definition, otto)
|
188
|
-
|
188
|
+
handler.call(env, extra_params)
|
189
189
|
else
|
190
190
|
# Fallback to legacy behavior for backward compatibility
|
191
191
|
inst = nil
|
@@ -205,7 +205,7 @@ class Otto
|
|
205
205
|
context = {
|
206
206
|
logic_instance: (kind == :instance ? inst : nil),
|
207
207
|
status_code: nil,
|
208
|
-
redirect_path: nil
|
208
|
+
redirect_path: nil,
|
209
209
|
}
|
210
210
|
|
211
211
|
Otto::ResponseHandlers::HandlerFactory.handle_response(result, res, response_type, context)
|
@@ -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
|