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.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  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 +24 -345
  7. data/CHANGELOG.rst +83 -0
  8. data/CLAUDE.md +56 -0
  9. data/Gemfile +10 -3
  10. data/Gemfile.lock +23 -28
  11. data/README.md +2 -0
  12. data/bin/rspec +4 -4
  13. data/changelog.d/20250911_235619_delano_next.rst +28 -0
  14. data/changelog.d/20250912_123055_delano_remove_ostruct.rst +21 -0
  15. data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +21 -0
  16. data/changelog.d/README.md +120 -0
  17. data/changelog.d/scriv.ini +5 -0
  18. data/docs/.gitignore +1 -0
  19. data/docs/migrating/v2.0.0-pre1.md +276 -0
  20. data/examples/.gitignore +1 -0
  21. data/examples/advanced_routes/README.md +33 -0
  22. data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
  23. data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
  24. data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
  25. data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
  26. data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
  27. data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
  28. data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
  29. data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
  30. data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
  31. data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
  32. data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
  33. data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
  34. data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
  35. data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
  36. data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
  37. data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
  38. data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
  39. data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
  40. data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
  41. data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
  42. data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
  43. data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
  44. data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
  45. data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
  46. data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
  47. data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
  48. data/examples/advanced_routes/app.rb +33 -0
  49. data/examples/advanced_routes/config.rb +23 -0
  50. data/examples/advanced_routes/config.ru +7 -0
  51. data/examples/advanced_routes/puma.rb +20 -0
  52. data/examples/advanced_routes/routes +167 -0
  53. data/examples/advanced_routes/run.rb +39 -0
  54. data/examples/advanced_routes/test.rb +58 -0
  55. data/examples/authentication_strategies/README.md +32 -0
  56. data/examples/authentication_strategies/app/auth.rb +68 -0
  57. data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
  58. data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
  59. data/examples/authentication_strategies/config.ru +24 -0
  60. data/examples/authentication_strategies/routes +37 -0
  61. data/examples/basic/README.md +29 -0
  62. data/examples/basic/app.rb +7 -35
  63. data/examples/basic/routes +0 -9
  64. data/examples/mcp_demo/README.md +87 -0
  65. data/examples/mcp_demo/app.rb +29 -34
  66. data/examples/mcp_demo/config.ru +9 -60
  67. data/examples/security_features/README.md +46 -0
  68. data/examples/security_features/app.rb +23 -24
  69. data/examples/security_features/config.ru +8 -10
  70. data/lib/otto/core/configuration.rb +167 -0
  71. data/lib/otto/core/error_handler.rb +86 -0
  72. data/lib/otto/core/file_safety.rb +61 -0
  73. data/lib/otto/core/middleware_stack.rb +157 -0
  74. data/lib/otto/core/router.rb +183 -0
  75. data/lib/otto/core/uri_generator.rb +44 -0
  76. data/lib/otto/design_system.rb +7 -5
  77. data/lib/otto/helpers/base.rb +3 -0
  78. data/lib/otto/helpers/request.rb +10 -8
  79. data/lib/otto/helpers/response.rb +5 -4
  80. data/lib/otto/helpers/validation.rb +9 -7
  81. data/lib/otto/mcp/auth/token.rb +10 -9
  82. data/lib/otto/mcp/protocol.rb +24 -27
  83. data/lib/otto/mcp/rate_limiting.rb +8 -3
  84. data/lib/otto/mcp/registry.rb +7 -2
  85. data/lib/otto/mcp/route_parser.rb +10 -15
  86. data/lib/otto/mcp/server.rb +21 -11
  87. data/lib/otto/mcp/validation.rb +14 -10
  88. data/lib/otto/response_handlers/auto.rb +39 -0
  89. data/lib/otto/response_handlers/base.rb +16 -0
  90. data/lib/otto/response_handlers/default.rb +16 -0
  91. data/lib/otto/response_handlers/factory.rb +39 -0
  92. data/lib/otto/response_handlers/json.rb +28 -0
  93. data/lib/otto/response_handlers/redirect.rb +25 -0
  94. data/lib/otto/response_handlers/view.rb +24 -0
  95. data/lib/otto/response_handlers.rb +9 -135
  96. data/lib/otto/route.rb +9 -9
  97. data/lib/otto/route_definition.rb +15 -18
  98. data/lib/otto/route_handlers/base.rb +121 -0
  99. data/lib/otto/route_handlers/class_method.rb +89 -0
  100. data/lib/otto/route_handlers/factory.rb +29 -0
  101. data/lib/otto/route_handlers/instance_method.rb +69 -0
  102. data/lib/otto/route_handlers/lambda.rb +59 -0
  103. data/lib/otto/route_handlers/logic_class.rb +93 -0
  104. data/lib/otto/route_handlers.rb +10 -405
  105. data/lib/otto/security/authentication/auth_strategy.rb +44 -0
  106. data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
  107. data/lib/otto/security/authentication/failure_result.rb +36 -0
  108. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
  109. data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
  110. data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -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 +223 -0
  114. data/lib/otto/security/authentication.rb +28 -282
  115. data/lib/otto/security/config.rb +14 -12
  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 +38 -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 +142 -498
  128. data/otto.gemspec +2 -2
  129. metadata +89 -28
  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
data/lib/otto.rb CHANGED
@@ -1,6 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto.rb
4
+
1
5
  require 'json'
2
6
  require 'logger'
3
- require 'ostruct'
4
7
  require 'securerandom'
5
8
  require 'uri'
6
9
 
@@ -8,6 +11,7 @@ require 'rack/request'
8
11
  require 'rack/response'
9
12
  require 'rack/utils'
10
13
 
14
+ require_relative 'otto/security/authentication/strategy_result'
11
15
  require_relative 'otto/route_definition'
12
16
  require_relative 'otto/route'
13
17
  require_relative 'otto/static'
@@ -17,11 +21,19 @@ require_relative 'otto/response_handlers'
17
21
  require_relative 'otto/route_handlers'
18
22
  require_relative 'otto/version'
19
23
  require_relative 'otto/security/config'
20
- require_relative 'otto/security/csrf'
21
- require_relative 'otto/security/validator'
22
- require_relative 'otto/security/authentication'
23
- require_relative 'otto/security/rate_limiting'
24
+ require_relative 'otto/security/middleware/csrf_middleware'
25
+ require_relative 'otto/security/middleware/validation_middleware'
26
+ require_relative 'otto/security/authentication/authentication_middleware'
27
+ require_relative 'otto/security/middleware/rate_limit_middleware'
24
28
  require_relative 'otto/mcp/server'
29
+ require_relative 'otto/core/router'
30
+ require_relative 'otto/core/file_safety'
31
+ require_relative 'otto/core/configuration'
32
+ require_relative 'otto/core/error_handler'
33
+ require_relative 'otto/core/uri_generator'
34
+ require_relative 'otto/core/middleware_stack'
35
+ require_relative 'otto/security/configurator'
36
+ require_relative 'otto/utils'
25
37
 
26
38
  # Otto is a simple Rack router that allows you to define routes in a file
27
39
  # with built-in security features including CSRF protection, input validation,
@@ -44,51 +56,78 @@ require_relative 'otto/mcp/server'
44
56
  # otto.enable_csp!
45
57
  # otto.enable_frame_protection!
46
58
  #
59
+ # Configuration Data class to replace OpenStruct
60
+ # Configuration Data class to replace OpenStruct
61
+ # Configuration class to replace OpenStruct
62
+ class ConfigData
63
+ def initialize(**kwargs)
64
+ @data = kwargs
65
+ end
66
+
67
+ # Dynamic attribute accessors
68
+ def method_missing(method_name, *args)
69
+ if method_name.to_s.end_with?('=')
70
+ # Setter
71
+ attr_name = method_name.to_s.chomp('=').to_sym
72
+ @data[attr_name] = args.first
73
+ elsif @data.key?(method_name)
74
+ # Getter
75
+ @data[method_name]
76
+ else
77
+ super
78
+ end
79
+ end
80
+
81
+ def respond_to_missing?(method_name, include_private = false)
82
+ method_name.to_s.end_with?('=') || @data.key?(method_name) || super
83
+ end
84
+
85
+ # Convert to hash for compatibility
86
+ def to_h
87
+ @data.dup
88
+ end
89
+ end
90
+
47
91
  class Otto
92
+ include Otto::Core::Router
93
+ include Otto::Core::FileSafety
94
+ include Otto::Core::Configuration
95
+ include Otto::Core::ErrorHandler
96
+ include Otto::Core::UriGenerator
97
+
48
98
  LIB_HOME = __dir__ unless defined?(Otto::LIB_HOME)
49
99
 
50
- @debug = ENV['OTTO_DEBUG'] == 'true'
51
- @logger = Logger.new($stdout, Logger::INFO)
52
- @global_config = {}
100
+ @debug = case ENV.fetch('OTTO_DEBUG', nil)
101
+ in 'true' | '1' | 'yes' | 'on'
102
+ true
103
+ else
104
+ defined?(Otto::Utils) ? Otto::Utils.yes?(ENV.fetch('OTTO_DEBUG', nil)) : false
105
+ end
106
+ @logger = Logger.new($stdout, Logger::INFO)
107
+ @global_config = nil
53
108
 
54
- # Global configuration for all Otto instances
109
+ # Global configuration for all Otto instances (Ruby 3.2+ pattern matching)
55
110
  def self.configure
56
- config = OpenStruct.new(@global_config)
111
+ config = case @global_config
112
+ in Hash => h
113
+ # Transform string keys to symbol keys for ConfigData compatibility
114
+ symbol_hash = h.transform_keys(&:to_sym)
115
+ ConfigData.new(**symbol_hash)
116
+ else
117
+ ConfigData.new
118
+ end
57
119
  yield config
58
120
  @global_config = config.to_h
59
121
  end
60
122
 
61
- def self.global_config
62
- @global_config
63
- end
64
-
65
- attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route, :security_config, :locale_config, :auth_config, :route_handler_factory, :mcp_server
66
- attr_accessor :not_found, :server_error, :middleware_stack
123
+ attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route,
124
+ :security_config, :locale_config, :auth_config, :route_handler_factory, :mcp_server, :security, :middleware
125
+ attr_accessor :not_found, :server_error
67
126
 
68
127
  def initialize(path = nil, opts = {})
69
- @routes_static = { GET: {} }
70
- @routes = { GET: [] }
71
- @routes_literal = { GET: {} }
72
- @route_definitions = {}
73
- @option = {
74
- public: nil,
75
- locale: 'en',
76
- }.merge(opts)
77
- @security_config = Otto::Security::Config.new
78
- @middleware_stack = []
79
- @route_handler_factory = opts[:route_handler_factory] || Otto::RouteHandlers::HandlerFactory
80
-
81
- # Configure locale support (merge global config with instance options)
82
- configure_locale(opts)
83
-
84
- # Configure security based on options
85
- configure_security(opts)
86
-
87
- # Configure authentication based on options
88
- configure_authentication(opts)
89
-
90
- # Initialize MCP server
91
- configure_mcp(opts)
128
+ initialize_core_state
129
+ initialize_options(path, opts)
130
+ initialize_configurations(opts)
92
131
 
93
132
  Otto.logger.debug "new Otto: #{opts}" if Otto.debug
94
133
  load(path) unless path.nil?
@@ -96,243 +135,44 @@ class Otto
96
135
  end
97
136
  alias options option
98
137
 
99
- def load(path)
100
- path = File.expand_path(path)
101
- raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)
102
-
103
- raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip }
104
- raw.each do |entry|
105
- # Enhanced parsing: split only on first two whitespace boundaries
106
- # This preserves parameters in the definition part
107
- parts = entry.split(/\s+/, 3)
108
- verb, path, definition = parts[0], parts[1], parts[2]
109
-
110
- # Check for MCP routes
111
- if Otto::MCP::RouteParser.is_mcp_route?(definition)
112
- handle_mcp_route(verb, path, definition) if @mcp_server
113
- next
114
- elsif Otto::MCP::RouteParser.is_tool_route?(definition)
115
- handle_tool_route(verb, path, definition) if @mcp_server
116
- next
117
- end
118
-
119
- route = Otto::Route.new verb, path, definition
120
- route.otto = self
121
- path_clean = path.gsub(%r{/$}, '')
122
- @route_definitions[route.definition] = route
123
- Otto.logger.debug "route: #{route.pattern}" if Otto.debug
124
- @routes[route.verb] ||= []
125
- @routes[route.verb] << route
126
- @routes_literal[route.verb] ||= {}
127
- @routes_literal[route.verb][path_clean] = route
128
- rescue StandardError
129
- Otto.logger.error "Bad route in #{path}: #{entry}"
130
- end
131
- self
132
- end
133
-
134
- def safe_file?(path)
135
- return false if option[:public].nil? || option[:public].empty?
136
- return false if path.nil? || path.empty?
137
-
138
- # Normalize and resolve the public directory path
139
- public_dir = File.expand_path(option[:public])
140
- return false unless File.directory?(public_dir)
141
-
142
- # Clean the requested path - remove null bytes and normalize
143
- clean_path = path.delete("\0").strip
144
- return false if clean_path.empty?
145
-
146
- # Join and expand to get the full resolved path
147
- requested_path = File.expand_path(File.join(public_dir, clean_path))
148
-
149
- # Ensure the resolved path is within the public directory (prevents path traversal)
150
- return false unless requested_path.start_with?(public_dir + File::SEPARATOR)
151
-
152
- # Check file exists, is readable, and is not a directory
153
- File.exist?(requested_path) &&
154
- File.readable?(requested_path) &&
155
- !File.directory?(requested_path) &&
156
- (File.owned?(requested_path) || File.grpowned?(requested_path))
157
- end
158
-
159
- def safe_dir?(path)
160
- return false if path.nil? || path.empty?
161
-
162
- # Clean and expand the path
163
- clean_path = path.delete("\0").strip
164
- return false if clean_path.empty?
165
-
166
- expanded_path = File.expand_path(clean_path)
167
-
168
- # Check directory exists, is readable, and has proper ownership
169
- File.directory?(expanded_path) &&
170
- File.readable?(expanded_path) &&
171
- (File.owned?(expanded_path) || File.grpowned?(expanded_path))
172
- end
173
-
174
- def add_static_path(path)
175
- return unless safe_file?(path)
176
-
177
- base_path = File.split(path).first
178
- # Files in the root directory can refer to themselves
179
- base_path = path if base_path == '/'
180
- File.join(option[:public], base_path)
181
- Otto.logger.debug "new static route: #{base_path} (#{path})" if Otto.debug
182
- routes_static[:GET][base_path] = base_path
183
- end
184
-
138
+ # Main Rack application interface
185
139
  def call(env)
186
140
  # Apply middleware stack
187
- app = ->(e) { handle_request(e) }
188
- @middleware_stack.reverse_each do |middleware|
189
- app = middleware.new(app, @security_config)
190
- end
141
+ base_app = ->(e) { handle_request(e) }
142
+
143
+ # Use the middleware stack as the source of truth
144
+ app = @middleware.build_app(base_app, @security_config)
191
145
 
192
146
  begin
193
147
  app.call(env)
194
- rescue StandardError => ex
195
- handle_error(ex, env)
148
+ rescue StandardError => e
149
+ handle_error(e, env)
196
150
  end
197
151
  end
198
152
 
199
- def handle_request(env)
200
- locale = determine_locale env
201
- env['rack.locale'] = locale
202
- env['otto.locale_config'] = @locale_config if @locale_config
203
- @static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
204
- path_info = Rack::Utils.unescape(env['PATH_INFO'])
205
- path_info = '/' if path_info.to_s.empty?
206
-
207
- begin
208
- path_info_clean = path_info
209
- .encode(
210
- 'UTF-8', # Target encoding
211
- invalid: :replace, # Replace invalid byte sequences
212
- undef: :replace, # Replace characters undefined in UTF-8
213
- replace: '', # Use empty string for replacement
214
- )
215
- .gsub(%r{/$}, '') # Remove trailing slash, if present
216
- rescue ArgumentError => ex
217
- # Log the error but don't expose details
218
- Otto.logger.error '[Otto.handle_request] Path encoding error'
219
- Otto.logger.debug "[Otto.handle_request] Error details: #{ex.message}" if Otto.debug
220
- # Set a default value or use the original path_info
221
- path_info_clean = path_info
222
- end
223
-
224
- base_path = File.split(path_info).first
225
- # Files in the root directory can refer to themselves
226
- base_path = path_info if base_path == '/'
227
- http_verb = env['REQUEST_METHOD'].upcase.to_sym
228
- literal_routes = routes_literal[http_verb] || {}
229
- literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
230
- if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
231
- # Otto.logger.debug " request: #{path_info} (static)"
232
- static_route.call(env)
233
- elsif literal_routes.has_key?(path_info_clean)
234
- route = literal_routes[path_info_clean]
235
- # Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
236
- route.call(env)
237
- elsif static_route && http_verb == :GET && safe_file?(path_info)
238
- Otto.logger.debug " new static route: #{base_path} (#{path_info})" if Otto.debug
239
- routes_static[:GET][base_path] = base_path
240
- static_route.call(env)
241
- else
242
- extra_params = {}
243
- found_route = nil
244
- valid_routes = routes[http_verb] || []
245
- valid_routes.push(*routes[:GET]) if http_verb == :HEAD
246
- valid_routes.each do |route|
247
- # Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
248
- next unless (match = route.pattern.match(path_info))
249
-
250
- values = match.captures.to_a
251
- # The first capture returned is the entire matched string b/c
252
- # we wrapped the entire regex in parens. We don't need it to
253
- # the full match.
254
- values.shift
255
- extra_params =
256
- if route.keys.any?
257
- route.keys.zip(values).each_with_object({}) do |(k, v), hash|
258
- if k == 'splat'
259
- (hash[k] ||= []) << v
260
- else
261
- hash[k] = v
262
- end
263
- end
264
- elsif values.any?
265
- { 'captures' => values }
266
- else
267
- {}
268
- end
269
- found_route = route
270
- break
271
- end
272
- found_route ||= literal_routes['/404']
273
- if found_route
274
- found_route.call env, extra_params
275
- else
276
- @not_found || Otto::Static.not_found
277
- end
278
- end
153
+ # Middleware Management
154
+ def use(middleware, ...)
155
+ @middleware.add(middleware, ...)
279
156
  end
280
157
 
281
- # Return the URI path for the given +route_definition+
282
- # e.g.
283
- #
284
- # Otto.default.path 'YourClass.somemethod' #=> /some/path
285
- #
286
- def uri(route_definition, params = {})
287
- # raise RuntimeError, "Not working"
288
- route = @route_definitions[route_definition]
289
- return if route.nil?
290
-
291
- local_params = params.clone
292
- local_path = route.path.clone
293
-
294
- local_params.each_pair do |k, v|
295
- next unless local_path.match(":#{k}")
296
-
297
- local_path.gsub!(":#{k}", v.to_s)
298
- local_params.delete(k)
299
- end
300
-
301
- uri = URI::HTTP.new(nil, nil, nil, nil, nil, local_path, nil, nil, nil)
302
- unless local_params.empty?
303
- query_string = local_params.map { |k, v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}" }.join('&')
304
- uri.query = query_string
305
- end
306
- uri.to_s
158
+ # Compatibility method for existing tests
159
+ def middleware_stack
160
+ @middleware.middleware_list
307
161
  end
308
162
 
309
- def determine_locale(env)
310
- accept_langs = env['HTTP_ACCEPT_LANGUAGE']
311
- accept_langs = option[:locale] if accept_langs.to_s.empty?
312
- locales = []
313
- unless accept_langs.empty?
314
- locales = accept_langs.split(',').map do |l|
315
- l += ';q=1.0' unless /;q=\d+(?:\.\d+)?$/.match?(l)
316
- l.split(';q=')
317
- end.sort_by do |_locale, qvalue|
318
- qvalue.to_f
319
- end.collect do |locale, _qvalue|
320
- locale
321
- end.reverse
322
- end
323
- Otto.logger.debug "locale: #{locales} (#{accept_langs})" if Otto.debug
324
- locales.empty? ? nil : locales
163
+ # Compatibility method for existing tests
164
+ def middleware_stack=(stack)
165
+ @middleware.clear!
166
+ Array(stack).each { |middleware| @middleware.add(middleware) }
325
167
  end
326
168
 
327
- # Add middleware to the stack
328
- #
329
- # @param middleware [Class] The middleware class to add
330
- # @param args [Array] Additional arguments for the middleware
331
- # @param block [Proc] Optional block for middleware configuration
332
- def use(middleware, *, &)
333
- @middleware_stack << middleware
169
+ # Compatibility method for middleware detection
170
+ def middleware_enabled?(middleware_class)
171
+ @middleware.includes?(middleware_class)
334
172
  end
335
173
 
174
+ # Security Configuration Methods
175
+
336
176
  # Enable CSRF protection for POST, PUT, DELETE, and PATCH requests.
337
177
  # This will automatically add CSRF tokens to HTML forms and validate
338
178
  # them on unsafe HTTP methods.
@@ -340,10 +180,10 @@ class Otto
340
180
  # @example
341
181
  # otto.enable_csrf_protection!
342
182
  def enable_csrf_protection!
343
- return if middleware_enabled?(Otto::Security::CSRFMiddleware)
183
+ return if @middleware.includes?(Otto::Security::Middleware::CSRFMiddleware)
344
184
 
345
185
  @security_config.enable_csrf_protection!
346
- use Otto::Security::CSRFMiddleware
186
+ use Otto::Security::Middleware::CSRFMiddleware
347
187
  end
348
188
 
349
189
  # Enable request validation including input sanitization, size limits,
@@ -352,10 +192,10 @@ class Otto
352
192
  # @example
353
193
  # otto.enable_request_validation!
354
194
  def enable_request_validation!
355
- return if middleware_enabled?(Otto::Security::ValidationMiddleware)
195
+ return if @middleware.includes?(Otto::Security::Middleware::ValidationMiddleware)
356
196
 
357
197
  @security_config.input_validation = true
358
- use Otto::Security::ValidationMiddleware
198
+ use Otto::Security::Middleware::ValidationMiddleware
359
199
  end
360
200
 
361
201
  # Enable rate limiting to protect against abuse and DDoS attacks.
@@ -367,27 +207,10 @@ class Otto
367
207
  # @example
368
208
  # otto.enable_rate_limiting!(requests_per_minute: 50)
369
209
  def enable_rate_limiting!(options = {})
370
- return if middleware_enabled?(Otto::Security::RateLimitMiddleware)
371
-
372
- configure_rate_limiting(options)
373
- use Otto::Security::RateLimitMiddleware
374
- end
210
+ return if @middleware.includes?(Otto::Security::Middleware::RateLimitMiddleware)
375
211
 
376
- # Configure rate limiting settings.
377
- #
378
- # @param config [Hash] Rate limiting configuration
379
- # @option config [Integer] :requests_per_minute Maximum requests per minute per IP
380
- # @option config [Hash] :custom_rules Hash of custom rate limiting rules
381
- # @option config [Object] :cache_store Custom cache store for rate limiting
382
- # @example
383
- # otto.configure_rate_limiting({
384
- # requests_per_minute: 50,
385
- # custom_rules: {
386
- # 'api_calls' => { limit: 30, period: 60, condition: ->(req) { req.path.start_with?('/api') }}
387
- # }
388
- # })
389
- def configure_rate_limiting(config)
390
- @security_config.rate_limiting_config.merge!(config)
212
+ @security.configure_rate_limiting(options)
213
+ use Otto::Security::Middleware::RateLimitMiddleware
391
214
  end
392
215
 
393
216
  # Add a custom rate limiting rule.
@@ -469,59 +292,27 @@ class Otto
469
292
  @security_config.enable_csp_with_nonce!(debug: debug)
470
293
  end
471
294
 
472
- # Configure locale settings for the application
473
- #
474
- # @param available_locales [Hash] Hash of available locales (e.g., { 'en' => 'English', 'es' => 'Spanish' })
475
- # @param default_locale [String] Default locale to use as fallback
476
- # @example
477
- # otto.configure(
478
- # available_locales: { 'en' => 'English', 'es' => 'Spanish', 'fr' => 'French' },
479
- # default_locale: 'en'
480
- # )
481
- def configure(available_locales: nil, default_locale: nil)
482
- @locale_config ||= {}
483
- @locale_config[:available_locales] = available_locales if available_locales
484
- @locale_config[:default_locale] = default_locale if default_locale
485
- end
486
-
487
295
  # Enable authentication middleware for route-level access control.
488
296
  # This will automatically check route auth parameters and enforce authentication.
489
297
  #
490
298
  # @example
491
299
  # otto.enable_authentication!
492
300
  def enable_authentication!
493
- return if middleware_enabled?(Otto::Security::AuthenticationMiddleware)
494
-
495
- use Otto::Security::AuthenticationMiddleware, @auth_config
496
- end
497
-
498
- # Configure authentication strategies for route-level access control.
499
- #
500
- # @param strategies [Hash] Hash mapping strategy names to strategy instances
501
- # @param default_strategy [String] Default strategy to use when none specified
502
- # @example
503
- # otto.configure_auth_strategies({
504
- # 'publically' => Otto::Security::PublicStrategy.new,
505
- # 'authenticated' => Otto::Security::SessionStrategy.new(session_key: 'user_id'),
506
- # 'role:admin' => Otto::Security::RoleStrategy.new(['admin']),
507
- # 'api_key' => Otto::Security::APIKeyStrategy.new(api_keys: ['secret123'])
508
- # })
509
- def configure_auth_strategies(strategies, default_strategy: 'publically')
510
- @auth_config ||= {}
511
- @auth_config[:auth_strategies] = strategies
512
- @auth_config[:default_auth_strategy] = default_strategy
301
+ return if @middleware.includes?(Otto::Security::Authentication::AuthenticationMiddleware)
513
302
 
514
- enable_authentication! unless strategies.empty?
303
+ use Otto::Security::Authentication::AuthenticationMiddleware, @auth_config
515
304
  end
516
305
 
517
306
  # Add a single authentication strategy
518
307
  #
519
308
  # @param name [String] Strategy name
520
- # @param strategy [Otto::Security::AuthStrategy] Strategy instance
309
+ # @param strategy [Otto::Security::Authentication::AuthStrategy] Strategy instance
521
310
  # @example
522
311
  # otto.add_auth_strategy('custom', MyCustomStrategy.new)
523
312
  def add_auth_strategy(name, strategy)
524
- @auth_config ||= { auth_strategies: {}, default_auth_strategy: 'publically' }
313
+ # Ensure auth_config is initialized (handles edge case where it might be nil)
314
+ @auth_config = { auth_strategies: {}, default_auth_strategy: 'publicly' } if @auth_config.nil?
315
+
525
316
  @auth_config[:auth_strategies][name] = strategy
526
317
 
527
318
  enable_authentication!
@@ -536,12 +327,10 @@ class Otto
536
327
  # @example
537
328
  # otto.enable_mcp!(http: true, endpoint: '/api/mcp')
538
329
  def enable_mcp!(options = {})
539
- unless @mcp_server
540
- @mcp_server = Otto::MCP::Server.new(self)
541
- end
330
+ @mcp_server ||= Otto::MCP::Server.new(self)
542
331
 
543
332
  @mcp_server.enable!(options)
544
- Otto.logger.info "[MCP] Enabled MCP server" if Otto.debug
333
+ Otto.logger.info '[MCP] Enabled MCP server' if Otto.debug
545
334
  end
546
335
 
547
336
  # Check if MCP is enabled
@@ -552,190 +341,45 @@ class Otto
552
341
 
553
342
  private
554
343
 
555
- def configure_locale(opts)
556
- # Start with global configuration
557
- global_config = self.class.global_config
558
- @locale_config = nil
559
-
560
- # Check if we have any locale configuration from any source
561
- has_global_locale = global_config && (global_config[:available_locales] || global_config[:default_locale])
562
- has_direct_options = opts[:available_locales] || opts[:default_locale]
563
- has_legacy_config = opts[:locale_config]
564
-
565
- # Only create locale_config if we have configuration from somewhere
566
- if has_global_locale || has_direct_options || has_legacy_config
567
- @locale_config = {}
568
-
569
- # Apply global configuration first
570
- @locale_config[:available_locales] = global_config[:available_locales] if global_config && global_config[:available_locales]
571
- @locale_config[:default_locale] = global_config[:default_locale] if global_config && global_config[:default_locale]
572
-
573
- # Apply direct instance options (these override global config)
574
- @locale_config[:available_locales] = opts[:available_locales] if opts[:available_locales]
575
- @locale_config[:default_locale] = opts[:default_locale] if opts[:default_locale]
576
-
577
- # Legacy support: Configure locale if provided in initialization options via locale_config hash
578
- if opts[:locale_config]
579
- locale_opts = opts[:locale_config]
580
- @locale_config[:available_locales] = locale_opts[:available_locales] || locale_opts[:available] if locale_opts[:available_locales] || locale_opts[:available]
581
- @locale_config[:default_locale] = locale_opts[:default_locale] || locale_opts[:default] if locale_opts[:default_locale] || locale_opts[:default]
582
- end
583
- end
584
- end
585
-
586
- def configure_security(opts)
587
- # Enable CSRF protection if requested
588
- enable_csrf_protection! if opts[:csrf_protection]
589
-
590
- # Enable request validation if requested
591
- enable_request_validation! if opts[:request_validation]
592
-
593
- # Enable rate limiting if requested
594
- if opts[:rate_limiting]
595
- rate_limiting_opts = opts[:rate_limiting].is_a?(Hash) ? opts[:rate_limiting] : {}
596
- enable_rate_limiting!(rate_limiting_opts)
597
- end
598
-
599
- # Add trusted proxies if provided
600
- if opts[:trusted_proxies]
601
- Array(opts[:trusted_proxies]).each { |proxy| add_trusted_proxy(proxy) }
602
- end
603
-
604
- # Set custom security headers
605
- if opts[:security_headers]
606
- set_security_headers(opts[:security_headers])
607
- end
608
- end
609
-
610
- def middleware_enabled?(middleware_class)
611
- @middleware_stack.any? { |m| m == middleware_class }
612
- end
613
-
614
- def configure_authentication(opts)
615
- # Configure authentication strategies
616
- @auth_config = {
617
- auth_strategies: opts[:auth_strategies] || {},
618
- default_auth_strategy: opts[:default_auth_strategy] || 'publically'
619
- }
620
-
621
- # Enable authentication middleware if strategies are configured
622
- if opts[:auth_strategies] && !opts[:auth_strategies].empty?
623
- enable_authentication!
624
- end
625
- end
626
-
627
- def configure_mcp(opts)
628
- @mcp_server = nil
629
-
630
- # Enable MCP if requested in options
631
- if opts[:mcp_enabled] || opts[:mcp_http] || opts[:mcp_stdio]
632
- @mcp_server = Otto::MCP::Server.new(self)
633
-
634
- mcp_options = {}
635
- mcp_options[:http_endpoint] = opts[:mcp_endpoint] if opts[:mcp_endpoint]
636
-
637
- if opts[:mcp_http] != false # Default to true unless explicitly disabled
638
- @mcp_server.enable!(mcp_options)
639
- end
640
- end
641
- end
642
-
643
- def handle_mcp_route(verb, path, definition)
644
- begin
645
- route_info = Otto::MCP::RouteParser.parse_mcp_route(verb, path, definition)
646
- @mcp_server.register_mcp_route(route_info)
647
- Otto.logger.debug "[MCP] Registered resource route: #{definition}" if Otto.debug
648
- rescue => e
649
- Otto.logger.error "[MCP] Failed to parse MCP route: #{definition} - #{e.message}"
650
- end
651
- end
652
-
653
- def handle_tool_route(verb, path, definition)
654
- begin
655
- route_info = Otto::MCP::RouteParser.parse_tool_route(verb, path, definition)
656
- @mcp_server.register_mcp_route(route_info)
657
- Otto.logger.debug "[MCP] Registered tool route: #{definition}" if Otto.debug
658
- rescue => e
659
- Otto.logger.error "[MCP] Failed to parse TOOL route: #{definition} - #{e.message}"
660
- end
344
+ def initialize_core_state
345
+ @routes_static = { GET: {} }
346
+ @routes = { GET: [] }
347
+ @routes_literal = { GET: {} }
348
+ @route_definitions = {}
349
+ @security_config = Otto::Security::Config.new
350
+ @middleware = Otto::Core::MiddlewareStack.new
351
+ # Initialize @auth_config first so it can be shared with the configurator
352
+ @auth_config = { auth_strategies: {}, default_auth_strategy: 'publicly' }
353
+ @security = Otto::Security::Configurator.new(@security_config, @middleware, @auth_config)
661
354
  end
662
355
 
663
- def handle_error(error, env)
664
- # Log error details internally but don't expose them
665
- error_id = SecureRandom.hex(8)
666
- Otto.logger.error "[#{error_id}] #{error.class}: #{error.message}"
667
- Otto.logger.debug "[#{error_id}] Backtrace: #{error.backtrace.join("\n")}" if Otto.debug
668
-
669
- # Parse request for content negotiation
670
- begin
671
- Rack::Request.new(env)
672
- rescue StandardError
673
- nil
674
- end
675
- literal_routes = @routes_literal[:GET] || {}
676
-
677
- # Try custom 500 route first
678
- if found_route = literal_routes['/500']
679
- begin
680
- env['otto.error_id'] = error_id
681
- return found_route.call(env)
682
- rescue StandardError => ex
683
- Otto.logger.error "[#{error_id}] Error in custom error handler: #{ex.message}"
684
- end
685
- end
686
-
687
- # Content negotiation for built-in error response
688
- accept_header = env['HTTP_ACCEPT'].to_s
689
- if accept_header.include?('application/json')
690
- return json_error_response(error_id)
691
- end
692
-
693
- # Fallback to built-in error response
694
- @server_error || secure_error_response(error_id)
356
+ def initialize_options(_path, opts)
357
+ @option = {
358
+ public: nil,
359
+ locale: 'en',
360
+ }.merge(opts)
361
+ @route_handler_factory = opts[:route_handler_factory] || Otto::RouteHandlers::HandlerFactory
695
362
  end
696
363
 
697
- def secure_error_response(error_id)
698
- body = if Otto.env?(:dev, :development)
699
- "Server error (ID: #{error_id}). Check logs for details."
700
- else
701
- 'An error occurred. Please try again later.'
702
- end
703
-
704
- headers = {
705
- 'content-type' => 'text/plain',
706
- 'content-length' => body.bytesize.to_s,
707
- }.merge(@security_config.security_headers)
708
-
709
- [500, headers, [body]]
710
- end
364
+ def initialize_configurations(opts)
365
+ # Configure locale support (merge global config with instance options)
366
+ configure_locale(opts)
711
367
 
712
- def json_error_response(error_id)
713
- error_data = if Otto.env?(:dev, :development)
714
- {
715
- error: 'Internal Server Error',
716
- message: 'Server error occurred. Check logs for details.',
717
- error_id: error_id,
718
- }
719
- else
720
- {
721
- error: 'Internal Server Error',
722
- message: 'An error occurred. Please try again later.',
723
- }
724
- end
368
+ # Configure security based on options
369
+ configure_security(opts)
725
370
 
726
- body = JSON.generate(error_data)
727
- headers = {
728
- 'content-type' => 'application/json',
729
- 'content-length' => body.bytesize.to_s,
730
- }.merge(@security_config.security_headers)
371
+ # Configure authentication based on options
372
+ configure_authentication(opts)
731
373
 
732
- [500, headers, [body]]
374
+ # Initialize MCP server
375
+ configure_mcp(opts)
733
376
  end
734
377
 
735
378
  class << self
736
- attr_accessor :debug, :logger
379
+ attr_accessor :debug, :logger, :global_config # rubocop:disable ThreadSafety/ClassAndModuleAttributes
737
380
  end
738
381
 
382
+ # Class methods for Otto framework providing singleton access and configuration
739
383
  module ClassMethods
740
384
  def default
741
385
  @default ||= Otto.new