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.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +3 -2
  3. data/.github/workflows/claude-code-review.yml +53 -0
  4. data/.github/workflows/claude.yml +49 -0
  5. data/.gitignore +3 -0
  6. data/.rubocop.yml +26 -344
  7. data/CHANGELOG.rst +131 -0
  8. data/CLAUDE.md +56 -0
  9. data/Gemfile +11 -4
  10. data/Gemfile.lock +38 -42
  11. data/README.md +2 -0
  12. data/bin/rspec +4 -4
  13. data/changelog.d/README.md +120 -0
  14. data/changelog.d/scriv.ini +5 -0
  15. data/docs/.gitignore +2 -0
  16. data/docs/migrating/v2.0.0-pre1.md +276 -0
  17. data/docs/migrating/v2.0.0-pre2.md +345 -0
  18. data/examples/.gitignore +1 -0
  19. data/examples/advanced_routes/README.md +33 -0
  20. data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
  21. data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
  22. data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
  23. data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
  24. data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
  25. data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
  26. data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
  27. data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
  28. data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
  29. data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
  30. data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
  31. data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
  32. data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
  33. data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
  34. data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
  35. data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
  36. data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
  37. data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
  38. data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
  39. data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
  40. data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
  41. data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
  42. data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
  43. data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
  44. data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
  45. data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
  46. data/examples/advanced_routes/app.rb +33 -0
  47. data/examples/advanced_routes/config.rb +23 -0
  48. data/examples/advanced_routes/config.ru +7 -0
  49. data/examples/advanced_routes/puma.rb +20 -0
  50. data/examples/advanced_routes/routes +167 -0
  51. data/examples/advanced_routes/run.rb +39 -0
  52. data/examples/advanced_routes/test.rb +58 -0
  53. data/examples/authentication_strategies/README.md +32 -0
  54. data/examples/authentication_strategies/app/auth.rb +68 -0
  55. data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
  56. data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
  57. data/examples/authentication_strategies/config.ru +24 -0
  58. data/examples/authentication_strategies/routes +37 -0
  59. data/examples/basic/README.md +29 -0
  60. data/examples/basic/app.rb +7 -35
  61. data/examples/basic/routes +0 -9
  62. data/examples/mcp_demo/README.md +87 -0
  63. data/examples/mcp_demo/app.rb +29 -34
  64. data/examples/mcp_demo/config.ru +9 -60
  65. data/examples/security_features/README.md +46 -0
  66. data/examples/security_features/app.rb +23 -24
  67. data/examples/security_features/config.ru +8 -10
  68. data/lib/otto/core/configuration.rb +167 -0
  69. data/lib/otto/core/error_handler.rb +86 -0
  70. data/lib/otto/core/file_safety.rb +61 -0
  71. data/lib/otto/core/middleware_stack.rb +237 -0
  72. data/lib/otto/core/router.rb +184 -0
  73. data/lib/otto/core/uri_generator.rb +44 -0
  74. data/lib/otto/design_system.rb +7 -5
  75. data/lib/otto/env_keys.rb +114 -0
  76. data/lib/otto/helpers/base.rb +5 -21
  77. data/lib/otto/helpers/request.rb +10 -8
  78. data/lib/otto/helpers/response.rb +27 -4
  79. data/lib/otto/helpers/validation.rb +9 -7
  80. data/lib/otto/mcp/auth/token.rb +10 -9
  81. data/lib/otto/mcp/protocol.rb +24 -27
  82. data/lib/otto/mcp/rate_limiting.rb +8 -3
  83. data/lib/otto/mcp/registry.rb +7 -2
  84. data/lib/otto/mcp/route_parser.rb +10 -15
  85. data/lib/otto/mcp/{validation.rb → schema_validation.rb} +16 -11
  86. data/lib/otto/mcp/server.rb +45 -22
  87. data/lib/otto/response_handlers/auto.rb +39 -0
  88. data/lib/otto/response_handlers/base.rb +16 -0
  89. data/lib/otto/response_handlers/default.rb +16 -0
  90. data/lib/otto/response_handlers/factory.rb +39 -0
  91. data/lib/otto/response_handlers/json.rb +34 -0
  92. data/lib/otto/response_handlers/redirect.rb +25 -0
  93. data/lib/otto/response_handlers/view.rb +24 -0
  94. data/lib/otto/response_handlers.rb +9 -135
  95. data/lib/otto/route.rb +51 -55
  96. data/lib/otto/route_definition.rb +15 -18
  97. data/lib/otto/route_handlers/base.rb +121 -0
  98. data/lib/otto/route_handlers/class_method.rb +89 -0
  99. data/lib/otto/route_handlers/factory.rb +42 -0
  100. data/lib/otto/route_handlers/instance_method.rb +69 -0
  101. data/lib/otto/route_handlers/lambda.rb +59 -0
  102. data/lib/otto/route_handlers/logic_class.rb +93 -0
  103. data/lib/otto/route_handlers.rb +10 -405
  104. data/lib/otto/security/authentication/auth_strategy.rb +44 -0
  105. data/lib/otto/security/authentication/authentication_middleware.rb +140 -0
  106. data/lib/otto/security/authentication/failure_result.rb +44 -0
  107. data/lib/otto/security/authentication/route_auth_wrapper.rb +149 -0
  108. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
  109. data/lib/otto/security/authentication/strategies/noauth_strategy.rb +19 -0
  110. data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
  111. data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
  112. data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
  113. data/lib/otto/security/authentication/strategy_result.rb +337 -0
  114. data/lib/otto/security/authentication.rb +28 -282
  115. data/lib/otto/security/config.rb +14 -23
  116. data/lib/otto/security/configurator.rb +219 -0
  117. data/lib/otto/security/csrf.rb +8 -143
  118. data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
  119. data/lib/otto/security/middleware/rate_limit_middleware.rb +54 -0
  120. data/lib/otto/security/middleware/validation_middleware.rb +252 -0
  121. data/lib/otto/security/rate_limiter.rb +86 -0
  122. data/lib/otto/security/rate_limiting.rb +10 -105
  123. data/lib/otto/security/validator.rb +8 -253
  124. data/lib/otto/static.rb +3 -0
  125. data/lib/otto/utils.rb +14 -0
  126. data/lib/otto/version.rb +3 -1
  127. data/lib/otto.rb +141 -498
  128. data/otto.gemspec +4 -2
  129. metadata +99 -18
  130. data/examples/dynamic_pages/app.rb +0 -115
  131. data/examples/dynamic_pages/config.ru +0 -30
  132. data/examples/dynamic_pages/routes +0 -21
  133. data/examples/helpers_demo/app.rb +0 -244
  134. data/examples/helpers_demo/config.ru +0 -26
  135. data/examples/helpers_demo/routes +0 -7
  136. data/lib/concurrent_cache_store.rb +0 -68
@@ -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 'validation'
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
- # Configure rate limiting first
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
- @otto_instance.use Otto::MCP::RateLimitMiddleware, @otto_instance.security_config
59
- Otto.logger.debug '[MCP] Rate limiting enabled' if Otto.debug
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 = Otto::MCP::Auth::TokenAuth.new(@auth_tokens)
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 (most expensive)
71
- if @enable_validation
72
- @otto_instance.use Otto::MCP::ValidationMiddleware
73
- Otto.logger.debug '[MCP] Request validation enabled' if Otto.debug
74
- end
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
- klass = Object.const_get(klass_name)
110
- klass.public_send(method_name)
111
- rescue StandardError => ex
112
- Otto.logger.error "[MCP] Resource handler error for #{uri}: #{ex.message}"
113
- raise
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
- # 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
- protected
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
- @pattern, @keys = *compile(path)
48
+ pattern, keys = *compile(path)
46
49
 
47
50
  # Create immutable route definition
48
- @route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern: @pattern, keys: @keys)
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 = req
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
- return handler.call(env, extra_params)
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
- if target.include?('.')
165
- klass_name, method_name = target.split('.', 2)
166
- { klass_name: klass_name, method_name: method_name, kind: :class }
167
- elsif target.include?('#')
168
- klass_name, method_name = target.split('#', 2)
169
- { klass_name: klass_name, method_name: method_name, kind: :instance }
170
- elsif target.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
171
- # Namespaced class with implicit method name (class method with same name as class)
172
- # E.g., "V2::Logic::Admin::Panel" -> Panel.Panel (class method)
173
- # For single word classes like "Logic", it's truly a logic class
174
- method_name = target.split('::').last
175
- if target.include?('::')
176
- # Namespaced class - treat as class method with implicit method name
177
- { klass_name: target, method_name: method_name, kind: :class }
178
- else
179
- # Single word class - treat as logic class
180
- { klass_name: target, method_name: method_name, kind: :logic }
181
- end
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