otto 1.5.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 +44 -5
  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 +21 -5
  10. data/Gemfile.lock +69 -31
  11. data/README.md +2 -0
  12. data/bin/rspec +16 -0
  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 +51 -0
  66. data/examples/mcp_demo/config.ru +17 -0
  67. data/examples/mcp_demo/routes +9 -0
  68. data/examples/security_features/README.md +46 -0
  69. data/examples/security_features/app.rb +23 -24
  70. data/examples/security_features/config.ru +8 -10
  71. data/lib/otto/core/configuration.rb +167 -0
  72. data/lib/otto/core/error_handler.rb +86 -0
  73. data/lib/otto/core/file_safety.rb +61 -0
  74. data/lib/otto/core/middleware_stack.rb +157 -0
  75. data/lib/otto/core/router.rb +183 -0
  76. data/lib/otto/core/uri_generator.rb +44 -0
  77. data/lib/otto/design_system.rb +7 -5
  78. data/lib/otto/helpers/base.rb +3 -0
  79. data/lib/otto/helpers/request.rb +10 -8
  80. data/lib/otto/helpers/response.rb +5 -4
  81. data/lib/otto/helpers/validation.rb +85 -0
  82. data/lib/otto/mcp/auth/token.rb +77 -0
  83. data/lib/otto/mcp/protocol.rb +164 -0
  84. data/lib/otto/mcp/rate_limiting.rb +155 -0
  85. data/lib/otto/mcp/registry.rb +100 -0
  86. data/lib/otto/mcp/route_parser.rb +77 -0
  87. data/lib/otto/mcp/server.rb +206 -0
  88. data/lib/otto/mcp/validation.rb +123 -0
  89. data/lib/otto/response_handlers/auto.rb +39 -0
  90. data/lib/otto/response_handlers/base.rb +16 -0
  91. data/lib/otto/response_handlers/default.rb +16 -0
  92. data/lib/otto/response_handlers/factory.rb +39 -0
  93. data/lib/otto/response_handlers/json.rb +28 -0
  94. data/lib/otto/response_handlers/redirect.rb +25 -0
  95. data/lib/otto/response_handlers/view.rb +24 -0
  96. data/lib/otto/response_handlers.rb +9 -135
  97. data/lib/otto/route.rb +9 -9
  98. data/lib/otto/route_definition.rb +30 -33
  99. data/lib/otto/route_handlers/base.rb +121 -0
  100. data/lib/otto/route_handlers/class_method.rb +89 -0
  101. data/lib/otto/route_handlers/factory.rb +29 -0
  102. data/lib/otto/route_handlers/instance_method.rb +69 -0
  103. data/lib/otto/route_handlers/lambda.rb +59 -0
  104. data/lib/otto/route_handlers/logic_class.rb +93 -0
  105. data/lib/otto/route_handlers.rb +10 -376
  106. data/lib/otto/security/authentication/auth_strategy.rb +44 -0
  107. data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
  108. data/lib/otto/security/authentication/failure_result.rb +36 -0
  109. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
  110. data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
  111. data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -0
  112. data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
  113. data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
  114. data/lib/otto/security/authentication/strategy_result.rb +223 -0
  115. data/lib/otto/security/authentication.rb +28 -282
  116. data/lib/otto/security/config.rb +15 -11
  117. data/lib/otto/security/configurator.rb +219 -0
  118. data/lib/otto/security/csrf.rb +8 -143
  119. data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
  120. data/lib/otto/security/middleware/rate_limit_middleware.rb +38 -0
  121. data/lib/otto/security/middleware/validation_middleware.rb +252 -0
  122. data/lib/otto/security/rate_limiter.rb +86 -0
  123. data/lib/otto/security/rate_limiting.rb +16 -0
  124. data/lib/otto/security/validator.rb +8 -292
  125. data/lib/otto/static.rb +3 -0
  126. data/lib/otto/utils.rb +14 -0
  127. data/lib/otto/version.rb +3 -1
  128. data/lib/otto.rb +184 -414
  129. data/otto.gemspec +11 -6
  130. metadata +134 -25
  131. data/examples/dynamic_pages/app.rb +0 -115
  132. data/examples/dynamic_pages/config.ru +0 -30
  133. data/examples/dynamic_pages/routes +0 -21
  134. data/examples/helpers_demo/app.rb +0 -244
  135. data/examples/helpers_demo/config.ru +0 -26
  136. data/examples/helpers_demo/routes +0 -7
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,9 +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'
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'
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'
23
37
 
24
38
  # Otto is a simple Rack router that allows you to define routes in a file
25
39
  # with built-in security features including CSRF protection, input validation,
@@ -42,48 +56,78 @@ require_relative 'otto/security/authentication'
42
56
  # otto.enable_csp!
43
57
  # otto.enable_frame_protection!
44
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
+
45
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
+
46
98
  LIB_HOME = __dir__ unless defined?(Otto::LIB_HOME)
47
99
 
48
- @debug = ENV['OTTO_DEBUG'] == 'true'
49
- @logger = Logger.new($stdout, Logger::INFO)
50
- @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
51
108
 
52
- # Global configuration for all Otto instances
109
+ # Global configuration for all Otto instances (Ruby 3.2+ pattern matching)
53
110
  def self.configure
54
- 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
55
119
  yield config
56
120
  @global_config = config.to_h
57
121
  end
58
122
 
59
- def self.global_config
60
- @global_config
61
- end
62
-
63
- attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route, :security_config, :locale_config, :auth_config, :route_handler_factory
64
- 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
65
126
 
66
127
  def initialize(path = nil, opts = {})
67
- @routes_static = { GET: {} }
68
- @routes = { GET: [] }
69
- @routes_literal = { GET: {} }
70
- @route_definitions = {}
71
- @option = {
72
- public: nil,
73
- locale: 'en',
74
- }.merge(opts)
75
- @security_config = Otto::Security::Config.new
76
- @middleware_stack = []
77
- @route_handler_factory = opts[:route_handler_factory] || Otto::RouteHandlers::HandlerFactory
78
-
79
- # Configure locale support (merge global config with instance options)
80
- configure_locale(opts)
81
-
82
- # Configure security based on options
83
- configure_security(opts)
84
-
85
- # Configure authentication based on options
86
- configure_authentication(opts)
128
+ initialize_core_state
129
+ initialize_options(path, opts)
130
+ initialize_configurations(opts)
87
131
 
88
132
  Otto.logger.debug "new Otto: #{opts}" if Otto.debug
89
133
  load(path) unless path.nil?
@@ -91,233 +135,44 @@ class Otto
91
135
  end
92
136
  alias options option
93
137
 
94
- def load(path)
95
- path = File.expand_path(path)
96
- raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)
97
-
98
- raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip }
99
- raw.each do |entry|
100
- # Enhanced parsing: split only on first two whitespace boundaries
101
- # This preserves parameters in the definition part
102
- parts = entry.split(/\s+/, 3)
103
- verb, path, definition = parts[0], parts[1], parts[2]
104
- route = Otto::Route.new verb, path, definition
105
- route.otto = self
106
- path_clean = path.gsub(%r{/$}, '')
107
- @route_definitions[route.definition] = route
108
- Otto.logger.debug "route: #{route.pattern}" if Otto.debug
109
- @routes[route.verb] ||= []
110
- @routes[route.verb] << route
111
- @routes_literal[route.verb] ||= {}
112
- @routes_literal[route.verb][path_clean] = route
113
- rescue StandardError
114
- Otto.logger.error "Bad route in #{path}: #{entry}"
115
- end
116
- self
117
- end
118
-
119
- def safe_file?(path)
120
- return false if option[:public].nil? || option[:public].empty?
121
- return false if path.nil? || path.empty?
122
-
123
- # Normalize and resolve the public directory path
124
- public_dir = File.expand_path(option[:public])
125
- return false unless File.directory?(public_dir)
126
-
127
- # Clean the requested path - remove null bytes and normalize
128
- clean_path = path.delete("\0").strip
129
- return false if clean_path.empty?
130
-
131
- # Join and expand to get the full resolved path
132
- requested_path = File.expand_path(File.join(public_dir, clean_path))
133
-
134
- # Ensure the resolved path is within the public directory (prevents path traversal)
135
- return false unless requested_path.start_with?(public_dir + File::SEPARATOR)
136
-
137
- # Check file exists, is readable, and is not a directory
138
- File.exist?(requested_path) &&
139
- File.readable?(requested_path) &&
140
- !File.directory?(requested_path) &&
141
- (File.owned?(requested_path) || File.grpowned?(requested_path))
142
- end
143
-
144
- def safe_dir?(path)
145
- return false if path.nil? || path.empty?
146
-
147
- # Clean and expand the path
148
- clean_path = path.delete("\0").strip
149
- return false if clean_path.empty?
150
-
151
- expanded_path = File.expand_path(clean_path)
152
-
153
- # Check directory exists, is readable, and has proper ownership
154
- File.directory?(expanded_path) &&
155
- File.readable?(expanded_path) &&
156
- (File.owned?(expanded_path) || File.grpowned?(expanded_path))
157
- end
158
-
159
- def add_static_path(path)
160
- return unless safe_file?(path)
161
-
162
- base_path = File.split(path).first
163
- # Files in the root directory can refer to themselves
164
- base_path = path if base_path == '/'
165
- File.join(option[:public], base_path)
166
- Otto.logger.debug "new static route: #{base_path} (#{path})" if Otto.debug
167
- routes_static[:GET][base_path] = base_path
168
- end
169
-
138
+ # Main Rack application interface
170
139
  def call(env)
171
140
  # Apply middleware stack
172
- app = ->(e) { handle_request(e) }
173
- @middleware_stack.reverse_each do |middleware|
174
- app = middleware.new(app, @security_config)
175
- 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)
176
145
 
177
146
  begin
178
147
  app.call(env)
179
- rescue StandardError => ex
180
- handle_error(ex, env)
148
+ rescue StandardError => e
149
+ handle_error(e, env)
181
150
  end
182
151
  end
183
152
 
184
- def handle_request(env)
185
- locale = determine_locale env
186
- env['rack.locale'] = locale
187
- env['otto.locale_config'] = @locale_config if @locale_config
188
- @static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
189
- path_info = Rack::Utils.unescape(env['PATH_INFO'])
190
- path_info = '/' if path_info.to_s.empty?
191
-
192
- begin
193
- path_info_clean = path_info
194
- .encode(
195
- 'UTF-8', # Target encoding
196
- invalid: :replace, # Replace invalid byte sequences
197
- undef: :replace, # Replace characters undefined in UTF-8
198
- replace: '', # Use empty string for replacement
199
- )
200
- .gsub(%r{/$}, '') # Remove trailing slash, if present
201
- rescue ArgumentError => ex
202
- # Log the error but don't expose details
203
- Otto.logger.error '[Otto.handle_request] Path encoding error'
204
- Otto.logger.debug "[Otto.handle_request] Error details: #{ex.message}" if Otto.debug
205
- # Set a default value or use the original path_info
206
- path_info_clean = path_info
207
- end
208
-
209
- base_path = File.split(path_info).first
210
- # Files in the root directory can refer to themselves
211
- base_path = path_info if base_path == '/'
212
- http_verb = env['REQUEST_METHOD'].upcase.to_sym
213
- literal_routes = routes_literal[http_verb] || {}
214
- literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
215
- if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
216
- # Otto.logger.debug " request: #{path_info} (static)"
217
- static_route.call(env)
218
- elsif literal_routes.has_key?(path_info_clean)
219
- route = literal_routes[path_info_clean]
220
- # Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
221
- route.call(env)
222
- elsif static_route && http_verb == :GET && safe_file?(path_info)
223
- Otto.logger.debug " new static route: #{base_path} (#{path_info})" if Otto.debug
224
- routes_static[:GET][base_path] = base_path
225
- static_route.call(env)
226
- else
227
- extra_params = {}
228
- found_route = nil
229
- valid_routes = routes[http_verb] || []
230
- valid_routes.push(*routes[:GET]) if http_verb == :HEAD
231
- valid_routes.each do |route|
232
- # Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
233
- next unless (match = route.pattern.match(path_info))
234
-
235
- values = match.captures.to_a
236
- # The first capture returned is the entire matched string b/c
237
- # we wrapped the entire regex in parens. We don't need it to
238
- # the full match.
239
- values.shift
240
- extra_params =
241
- if route.keys.any?
242
- route.keys.zip(values).each_with_object({}) do |(k, v), hash|
243
- if k == 'splat'
244
- (hash[k] ||= []) << v
245
- else
246
- hash[k] = v
247
- end
248
- end
249
- elsif values.any?
250
- { 'captures' => values }
251
- else
252
- {}
253
- end
254
- found_route = route
255
- break
256
- end
257
- found_route ||= literal_routes['/404']
258
- if found_route
259
- found_route.call env, extra_params
260
- else
261
- @not_found || Otto::Static.not_found
262
- end
263
- end
153
+ # Middleware Management
154
+ def use(middleware, ...)
155
+ @middleware.add(middleware, ...)
264
156
  end
265
157
 
266
- # Return the URI path for the given +route_definition+
267
- # e.g.
268
- #
269
- # Otto.default.path 'YourClass.somemethod' #=> /some/path
270
- #
271
- def uri(route_definition, params = {})
272
- # raise RuntimeError, "Not working"
273
- route = @route_definitions[route_definition]
274
- return if route.nil?
275
-
276
- local_params = params.clone
277
- local_path = route.path.clone
278
-
279
- local_params.each_pair do |k, v|
280
- next unless local_path.match(":#{k}")
281
-
282
- local_path.gsub!(":#{k}", v.to_s)
283
- local_params.delete(k)
284
- end
285
-
286
- uri = URI::HTTP.new(nil, nil, nil, nil, nil, local_path, nil, nil, nil)
287
- unless local_params.empty?
288
- query_string = local_params.map { |k, v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}" }.join('&')
289
- uri.query = query_string
290
- end
291
- uri.to_s
158
+ # Compatibility method for existing tests
159
+ def middleware_stack
160
+ @middleware.middleware_list
292
161
  end
293
162
 
294
- def determine_locale(env)
295
- accept_langs = env['HTTP_ACCEPT_LANGUAGE']
296
- accept_langs = option[:locale] if accept_langs.to_s.empty?
297
- locales = []
298
- unless accept_langs.empty?
299
- locales = accept_langs.split(',').map do |l|
300
- l += ';q=1.0' unless /;q=\d+(?:\.\d+)?$/.match?(l)
301
- l.split(';q=')
302
- end.sort_by do |_locale, qvalue|
303
- qvalue.to_f
304
- end.collect do |locale, _qvalue|
305
- locale
306
- end.reverse
307
- end
308
- Otto.logger.debug "locale: #{locales} (#{accept_langs})" if Otto.debug
309
- 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) }
310
167
  end
311
168
 
312
- # Add middleware to the stack
313
- #
314
- # @param middleware [Class] The middleware class to add
315
- # @param args [Array] Additional arguments for the middleware
316
- # @param block [Proc] Optional block for middleware configuration
317
- def use(middleware, *, &)
318
- @middleware_stack << middleware
169
+ # Compatibility method for middleware detection
170
+ def middleware_enabled?(middleware_class)
171
+ @middleware.includes?(middleware_class)
319
172
  end
320
173
 
174
+ # Security Configuration Methods
175
+
321
176
  # Enable CSRF protection for POST, PUT, DELETE, and PATCH requests.
322
177
  # This will automatically add CSRF tokens to HTML forms and validate
323
178
  # them on unsafe HTTP methods.
@@ -325,10 +180,10 @@ class Otto
325
180
  # @example
326
181
  # otto.enable_csrf_protection!
327
182
  def enable_csrf_protection!
328
- return if middleware_enabled?(Otto::Security::CSRFMiddleware)
183
+ return if @middleware.includes?(Otto::Security::Middleware::CSRFMiddleware)
329
184
 
330
185
  @security_config.enable_csrf_protection!
331
- use Otto::Security::CSRFMiddleware
186
+ use Otto::Security::Middleware::CSRFMiddleware
332
187
  end
333
188
 
334
189
  # Enable request validation including input sanitization, size limits,
@@ -337,10 +192,39 @@ class Otto
337
192
  # @example
338
193
  # otto.enable_request_validation!
339
194
  def enable_request_validation!
340
- return if middleware_enabled?(Otto::Security::ValidationMiddleware)
195
+ return if @middleware.includes?(Otto::Security::Middleware::ValidationMiddleware)
341
196
 
342
197
  @security_config.input_validation = true
343
- use Otto::Security::ValidationMiddleware
198
+ use Otto::Security::Middleware::ValidationMiddleware
199
+ end
200
+
201
+ # Enable rate limiting to protect against abuse and DDoS attacks.
202
+ # This will automatically add rate limiting rules based on client IP.
203
+ #
204
+ # @param options [Hash] Rate limiting configuration options
205
+ # @option options [Integer] :requests_per_minute Maximum requests per minute per IP (default: 100)
206
+ # @option options [Hash] :custom_rules Custom rate limiting rules
207
+ # @example
208
+ # otto.enable_rate_limiting!(requests_per_minute: 50)
209
+ def enable_rate_limiting!(options = {})
210
+ return if @middleware.includes?(Otto::Security::Middleware::RateLimitMiddleware)
211
+
212
+ @security.configure_rate_limiting(options)
213
+ use Otto::Security::Middleware::RateLimitMiddleware
214
+ end
215
+
216
+ # Add a custom rate limiting rule.
217
+ #
218
+ # @param name [String, Symbol] Rule name
219
+ # @param options [Hash] Rule configuration
220
+ # @option options [Integer] :limit Maximum requests
221
+ # @option options [Integer] :period Time period in seconds (default: 60)
222
+ # @option options [Proc] :condition Optional condition proc that receives request
223
+ # @example
224
+ # otto.add_rate_limit_rule('uploads', limit: 5, period: 300, condition: ->(req) { req.post? && req.path.include?('upload') })
225
+ def add_rate_limit_rule(name, options)
226
+ @security_config.rate_limiting_config[:custom_rules] ||= {}
227
+ @security_config.rate_limiting_config[:custom_rules][name.to_s] = options
344
228
  end
345
229
 
346
230
  # Add a trusted proxy server for accurate client IP detection.
@@ -408,208 +292,94 @@ class Otto
408
292
  @security_config.enable_csp_with_nonce!(debug: debug)
409
293
  end
410
294
 
411
- # Configure locale settings for the application
412
- #
413
- # @param available_locales [Hash] Hash of available locales (e.g., { 'en' => 'English', 'es' => 'Spanish' })
414
- # @param default_locale [String] Default locale to use as fallback
415
- # @example
416
- # otto.configure(
417
- # available_locales: { 'en' => 'English', 'es' => 'Spanish', 'fr' => 'French' },
418
- # default_locale: 'en'
419
- # )
420
- def configure(available_locales: nil, default_locale: nil)
421
- @locale_config ||= {}
422
- @locale_config[:available_locales] = available_locales if available_locales
423
- @locale_config[:default_locale] = default_locale if default_locale
424
- end
425
-
426
295
  # Enable authentication middleware for route-level access control.
427
296
  # This will automatically check route auth parameters and enforce authentication.
428
297
  #
429
298
  # @example
430
299
  # otto.enable_authentication!
431
300
  def enable_authentication!
432
- return if middleware_enabled?(Otto::Security::AuthenticationMiddleware)
301
+ return if @middleware.includes?(Otto::Security::Authentication::AuthenticationMiddleware)
433
302
 
434
- use Otto::Security::AuthenticationMiddleware, @auth_config
435
- end
436
-
437
- # Configure authentication strategies for route-level access control.
438
- #
439
- # @param strategies [Hash] Hash mapping strategy names to strategy instances
440
- # @param default_strategy [String] Default strategy to use when none specified
441
- # @example
442
- # otto.configure_auth_strategies({
443
- # 'publically' => Otto::Security::PublicStrategy.new,
444
- # 'authenticated' => Otto::Security::SessionStrategy.new(session_key: 'user_id'),
445
- # 'role:admin' => Otto::Security::RoleStrategy.new(['admin']),
446
- # 'api_key' => Otto::Security::APIKeyStrategy.new(api_keys: ['secret123'])
447
- # })
448
- def configure_auth_strategies(strategies, default_strategy: 'publically')
449
- @auth_config ||= {}
450
- @auth_config[:auth_strategies] = strategies
451
- @auth_config[:default_auth_strategy] = default_strategy
452
-
453
- enable_authentication! unless strategies.empty?
303
+ use Otto::Security::Authentication::AuthenticationMiddleware, @auth_config
454
304
  end
455
305
 
456
306
  # Add a single authentication strategy
457
307
  #
458
308
  # @param name [String] Strategy name
459
- # @param strategy [Otto::Security::AuthStrategy] Strategy instance
309
+ # @param strategy [Otto::Security::Authentication::AuthStrategy] Strategy instance
460
310
  # @example
461
311
  # otto.add_auth_strategy('custom', MyCustomStrategy.new)
462
312
  def add_auth_strategy(name, strategy)
463
- @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
+
464
316
  @auth_config[:auth_strategies][name] = strategy
465
317
 
466
318
  enable_authentication!
467
319
  end
468
320
 
469
- private
321
+ # Enable MCP (Model Context Protocol) server support
322
+ #
323
+ # @param options [Hash] MCP configuration options
324
+ # @option options [Boolean] :http Enable HTTP endpoint (default: true)
325
+ # @option options [Boolean] :stdio Enable STDIO communication (default: false)
326
+ # @option options [String] :endpoint HTTP endpoint path (default: '/_mcp')
327
+ # @example
328
+ # otto.enable_mcp!(http: true, endpoint: '/api/mcp')
329
+ def enable_mcp!(options = {})
330
+ @mcp_server ||= Otto::MCP::Server.new(self)
470
331
 
471
- def configure_locale(opts)
472
- # Start with global configuration
473
- global_config = self.class.global_config
474
- @locale_config = nil
475
-
476
- # Check if we have any locale configuration from any source
477
- has_global_locale = global_config && (global_config[:available_locales] || global_config[:default_locale])
478
- has_direct_options = opts[:available_locales] || opts[:default_locale]
479
- has_legacy_config = opts[:locale_config]
480
-
481
- # Only create locale_config if we have configuration from somewhere
482
- if has_global_locale || has_direct_options || has_legacy_config
483
- @locale_config = {}
484
-
485
- # Apply global configuration first
486
- @locale_config[:available_locales] = global_config[:available_locales] if global_config && global_config[:available_locales]
487
- @locale_config[:default_locale] = global_config[:default_locale] if global_config && global_config[:default_locale]
488
-
489
- # Apply direct instance options (these override global config)
490
- @locale_config[:available_locales] = opts[:available_locales] if opts[:available_locales]
491
- @locale_config[:default_locale] = opts[:default_locale] if opts[:default_locale]
492
-
493
- # Legacy support: Configure locale if provided in initialization options via locale_config hash
494
- if opts[:locale_config]
495
- locale_opts = opts[:locale_config]
496
- @locale_config[:available_locales] = locale_opts[:available_locales] || locale_opts[:available] if locale_opts[:available_locales] || locale_opts[:available]
497
- @locale_config[:default_locale] = locale_opts[:default_locale] || locale_opts[:default] if locale_opts[:default_locale] || locale_opts[:default]
498
- end
499
- end
332
+ @mcp_server.enable!(options)
333
+ Otto.logger.info '[MCP] Enabled MCP server' if Otto.debug
500
334
  end
501
335
 
502
- def configure_security(opts)
503
- # Enable CSRF protection if requested
504
- enable_csrf_protection! if opts[:csrf_protection]
505
-
506
- # Enable request validation if requested
507
- enable_request_validation! if opts[:request_validation]
508
-
509
- # Add trusted proxies if provided
510
- if opts[:trusted_proxies]
511
- Array(opts[:trusted_proxies]).each { |proxy| add_trusted_proxy(proxy) }
512
- end
513
-
514
- # Set custom security headers
515
- if opts[:security_headers]
516
- set_security_headers(opts[:security_headers])
517
- end
336
+ # Check if MCP is enabled
337
+ # @return [Boolean]
338
+ def mcp_enabled?
339
+ @mcp_server&.enabled?
518
340
  end
519
341
 
520
- def middleware_enabled?(middleware_class)
521
- @middleware_stack.any? { |m| m == middleware_class }
522
- end
523
-
524
- def configure_authentication(opts)
525
- # Configure authentication strategies
526
- @auth_config = {
527
- auth_strategies: opts[:auth_strategies] || {},
528
- default_auth_strategy: opts[:default_auth_strategy] || 'publically'
529
- }
342
+ private
530
343
 
531
- # Enable authentication middleware if strategies are configured
532
- if opts[:auth_strategies] && !opts[:auth_strategies].empty?
533
- enable_authentication!
534
- 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)
535
354
  end
536
355
 
537
- def handle_error(error, env)
538
- # Log error details internally but don't expose them
539
- error_id = SecureRandom.hex(8)
540
- Otto.logger.error "[#{error_id}] #{error.class}: #{error.message}"
541
- Otto.logger.debug "[#{error_id}] Backtrace: #{error.backtrace.join("\n")}" if Otto.debug
542
-
543
- # Parse request for content negotiation
544
- begin
545
- Rack::Request.new(env)
546
- rescue StandardError
547
- nil
548
- end
549
- literal_routes = @routes_literal[:GET] || {}
550
-
551
- # Try custom 500 route first
552
- if found_route = literal_routes['/500']
553
- begin
554
- env['otto.error_id'] = error_id
555
- return found_route.call(env)
556
- rescue StandardError => ex
557
- Otto.logger.error "[#{error_id}] Error in custom error handler: #{ex.message}"
558
- end
559
- end
560
-
561
- # Content negotiation for built-in error response
562
- accept_header = env['HTTP_ACCEPT'].to_s
563
- if accept_header.include?('application/json')
564
- return json_error_response(error_id)
565
- end
566
-
567
- # Fallback to built-in error response
568
- @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
569
362
  end
570
363
 
571
- def secure_error_response(error_id)
572
- body = if Otto.env?(:dev, :development)
573
- "Server error (ID: #{error_id}). Check logs for details."
574
- else
575
- 'An error occurred. Please try again later.'
576
- end
577
-
578
- headers = {
579
- 'content-type' => 'text/plain',
580
- 'content-length' => body.bytesize.to_s,
581
- }.merge(@security_config.security_headers)
582
-
583
- [500, headers, [body]]
584
- end
364
+ def initialize_configurations(opts)
365
+ # Configure locale support (merge global config with instance options)
366
+ configure_locale(opts)
585
367
 
586
- def json_error_response(error_id)
587
- error_data = if Otto.env?(:dev, :development)
588
- {
589
- error: 'Internal Server Error',
590
- message: 'Server error occurred. Check logs for details.',
591
- error_id: error_id,
592
- }
593
- else
594
- {
595
- error: 'Internal Server Error',
596
- message: 'An error occurred. Please try again later.',
597
- }
598
- end
368
+ # Configure security based on options
369
+ configure_security(opts)
599
370
 
600
- body = JSON.generate(error_data)
601
- headers = {
602
- 'content-type' => 'application/json',
603
- 'content-length' => body.bytesize.to_s,
604
- }.merge(@security_config.security_headers)
371
+ # Configure authentication based on options
372
+ configure_authentication(opts)
605
373
 
606
- [500, headers, [body]]
374
+ # Initialize MCP server
375
+ configure_mcp(opts)
607
376
  end
608
377
 
609
378
  class << self
610
- attr_accessor :debug, :logger
379
+ attr_accessor :debug, :logger, :global_config # rubocop:disable ThreadSafety/ClassAndModuleAttributes
611
380
  end
612
381
 
382
+ # Class methods for Otto framework providing singleton access and configuration
613
383
  module ClassMethods
614
384
  def default
615
385
  @default ||= Otto.new