otto 2.0.0.pre3 → 2.0.0.pre8

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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +1 -1
  4. data/.github/workflows/code-smells.yml +143 -0
  5. data/.gitignore +4 -0
  6. data/.pre-commit-config.yaml +2 -2
  7. data/.reek.yml +99 -0
  8. data/CHANGELOG.rst +156 -0
  9. data/CLAUDE.md +74 -540
  10. data/Gemfile +4 -2
  11. data/Gemfile.lock +58 -19
  12. data/README.md +49 -1
  13. data/examples/advanced_routes/README.md +137 -20
  14. data/examples/authentication_strategies/README.md +212 -19
  15. data/examples/backtrace_sanitization_demo.rb +86 -0
  16. data/examples/basic/README.md +61 -10
  17. data/examples/error_handler_registration.rb +136 -0
  18. data/examples/logging_improvements.rb +76 -0
  19. data/examples/mcp_demo/README.md +187 -27
  20. data/examples/security_features/README.md +249 -30
  21. data/examples/simple_geo_resolver.rb +107 -0
  22. data/lib/otto/core/configuration.rb +15 -20
  23. data/lib/otto/core/error_handler.rb +138 -8
  24. data/lib/otto/core/file_safety.rb +2 -2
  25. data/lib/otto/core/freezable.rb +2 -2
  26. data/lib/otto/core/middleware_stack.rb +2 -2
  27. data/lib/otto/core/router.rb +61 -8
  28. data/lib/otto/core/uri_generator.rb +2 -2
  29. data/lib/otto/core.rb +2 -0
  30. data/lib/otto/design_system.rb +2 -2
  31. data/lib/otto/env_keys.rb +61 -12
  32. data/lib/otto/helpers/base.rb +2 -2
  33. data/lib/otto/helpers/request.rb +8 -3
  34. data/lib/otto/helpers/response.rb +2 -2
  35. data/lib/otto/helpers/validation.rb +2 -2
  36. data/lib/otto/helpers.rb +2 -0
  37. data/lib/otto/locale/config.rb +2 -2
  38. data/lib/otto/locale/middleware.rb +160 -0
  39. data/lib/otto/locale.rb +10 -0
  40. data/lib/otto/logging_helpers.rb +273 -0
  41. data/lib/otto/mcp/auth/token.rb +2 -2
  42. data/lib/otto/mcp/protocol.rb +2 -2
  43. data/lib/otto/mcp/rate_limiting.rb +2 -2
  44. data/lib/otto/mcp/registry.rb +2 -2
  45. data/lib/otto/mcp/route_parser.rb +2 -2
  46. data/lib/otto/mcp/schema_validation.rb +2 -2
  47. data/lib/otto/mcp/server.rb +2 -2
  48. data/lib/otto/mcp.rb +2 -0
  49. data/lib/otto/privacy/config.rb +2 -0
  50. data/lib/otto/privacy/geo_resolver.rb +199 -29
  51. data/lib/otto/privacy/ip_privacy.rb +2 -0
  52. data/lib/otto/privacy/redacted_fingerprint.rb +18 -8
  53. data/lib/otto/privacy.rb +2 -0
  54. data/lib/otto/response_handlers/auto.rb +2 -0
  55. data/lib/otto/response_handlers/base.rb +2 -0
  56. data/lib/otto/response_handlers/default.rb +2 -0
  57. data/lib/otto/response_handlers/factory.rb +2 -0
  58. data/lib/otto/response_handlers/json.rb +2 -0
  59. data/lib/otto/response_handlers/redirect.rb +2 -0
  60. data/lib/otto/response_handlers/view.rb +2 -0
  61. data/lib/otto/response_handlers.rb +2 -2
  62. data/lib/otto/route.rb +4 -4
  63. data/lib/otto/route_definition.rb +42 -15
  64. data/lib/otto/route_handlers/base.rb +2 -0
  65. data/lib/otto/route_handlers/class_method.rb +26 -26
  66. data/lib/otto/route_handlers/factory.rb +2 -2
  67. data/lib/otto/route_handlers/instance_method.rb +16 -6
  68. data/lib/otto/route_handlers/lambda.rb +8 -20
  69. data/lib/otto/route_handlers/logic_class.rb +33 -8
  70. data/lib/otto/route_handlers.rb +2 -2
  71. data/lib/otto/security/authentication/auth_failure.rb +2 -2
  72. data/lib/otto/security/authentication/auth_strategy.rb +11 -4
  73. data/lib/otto/security/authentication/route_auth_wrapper/response_builder.rb +123 -0
  74. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +120 -0
  75. data/lib/otto/security/authentication/route_auth_wrapper/strategy_resolver.rb +69 -0
  76. data/lib/otto/security/authentication/route_auth_wrapper.rb +185 -195
  77. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
  78. data/lib/otto/security/authentication/strategies/noauth_strategy.rb +2 -0
  79. data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
  80. data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
  81. data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
  82. data/lib/otto/security/authentication/strategy_result.rb +6 -5
  83. data/lib/otto/security/authentication.rb +2 -2
  84. data/lib/otto/security/authorization_error.rb +73 -0
  85. data/lib/otto/security/config.rb +2 -2
  86. data/lib/otto/security/configurator.rb +17 -2
  87. data/lib/otto/security/csrf.rb +2 -2
  88. data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
  89. data/lib/otto/security/middleware/ip_privacy_middleware.rb +31 -11
  90. data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
  91. data/lib/otto/security/middleware/validation_middleware.rb +15 -0
  92. data/lib/otto/security/rate_limiter.rb +2 -2
  93. data/lib/otto/security/rate_limiting.rb +2 -2
  94. data/lib/otto/security/validator.rb +2 -2
  95. data/lib/otto/security.rb +3 -0
  96. data/lib/otto/static.rb +2 -2
  97. data/lib/otto/utils.rb +27 -2
  98. data/lib/otto/version.rb +3 -3
  99. data/lib/otto.rb +174 -14
  100. data/otto.gemspec +7 -3
  101. metadata +25 -15
  102. data/benchmark_middleware_wrap.rb +0 -163
  103. data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +0 -36
  104. data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +0 -5
@@ -1,3 +1,5 @@
1
+ # lib/otto/security/middleware/csrf_middleware.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative '../config'
@@ -27,7 +29,15 @@ class Otto
27
29
  end
28
30
 
29
31
  # Validate CSRF token for unsafe methods
30
- return csrf_error_response unless valid_csrf_token?(request)
32
+ unless valid_csrf_token?(request)
33
+ # Log CSRF validation failure
34
+ Otto.structured_log(:warn, "CSRF validation failed",
35
+ Otto::LoggingHelpers.request_context(env).merge(
36
+ referrer: request.referrer
37
+ )
38
+ )
39
+ return csrf_error_response
40
+ end
31
41
 
32
42
  @app.call(env)
33
43
  end
@@ -1,3 +1,5 @@
1
+ # lib/otto/security/middleware/ip_privacy_middleware.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  class Otto
@@ -87,24 +89,32 @@ class Otto
87
89
  env['REMOTE_ADDR'] = original_remote_addr
88
90
 
89
91
  # Set privacy-safe values in environment
90
- env['otto.redacted_fingerprint'] = fingerprint
91
- env['otto.masked_ip'] = fingerprint.masked_ip
92
- env['otto.hashed_ip'] = fingerprint.hashed_ip
93
- env['otto.geo_country'] = fingerprint.country
92
+ env['otto.privacy.fingerprint'] = fingerprint
93
+ env['otto.privacy.masked_ip'] = fingerprint.masked_ip
94
+ env['otto.privacy.hashed_ip'] = fingerprint.hashed_ip
95
+ env['otto.privacy.geo_country'] = fingerprint.country
94
96
 
95
- # CRITICAL: Replace REMOTE_ADDR and forwarded headers with masked IP
97
+ # CRITICAL: Replace REMOTE_ADDR and forwarded headers with masked values
96
98
  # This ensures downstream code (rate limiting, auth, logging, Rack's request.ip)
97
- # automatically uses the masked IP without modification
99
+ # automatically uses the masked values without modification
98
100
  env['REMOTE_ADDR'] = fingerprint.masked_ip
99
101
 
102
+ # Replace User-Agent with anonymized version (consistent with IP masking)
103
+ # CRITICAL: Always replace, even if nil, to clear original sensitive data
104
+ env['HTTP_USER_AGENT'] = fingerprint.anonymized_ua
105
+
106
+ # Replace Referer with anonymized version (query params stripped)
107
+ # CRITICAL: Always replace, even if nil, to clear original sensitive data
108
+ env['HTTP_REFERER'] = fingerprint.referer
109
+
100
110
  # Mask X-Forwarded-For headers to prevent leakage
101
111
  # Replace with masked IP so proxy resolution logic finds the masked IP
102
112
  mask_forwarded_headers(env, fingerprint.masked_ip)
103
113
 
104
114
  Otto.logger.debug "[IPPrivacyMiddleware] Masked IP: #{fingerprint.masked_ip}" if Otto.debug
105
115
 
106
- # NOTE: We deliberately DO NOT set env['otto.original_ip']
107
- # This prevents accidental leakage of the real IP address
116
+ # NOTE: We deliberately DO NOT set env['otto.original_ip'], env['otto.original_user_agent'],
117
+ # or env['otto.original_referer']. This prevents accidental leakage of the real values.
108
118
  end
109
119
 
110
120
 
@@ -199,10 +209,20 @@ class Otto
199
209
  #
200
210
  # @param env [Hash] Rack environment
201
211
  def apply_no_privacy(env)
202
- # Store original IP for explicit access
203
- env['otto.original_ip'] = env['REMOTE_ADDR'].dup.force_encoding('UTF-8')
212
+ # Store original values for explicit access when privacy is disabled
213
+ if env['REMOTE_ADDR']
214
+ env['otto.original_ip'] = env['REMOTE_ADDR'].dup.force_encoding('UTF-8')
215
+ end
216
+
217
+ if env['HTTP_USER_AGENT']
218
+ env['otto.original_user_agent'] = env['HTTP_USER_AGENT'].dup.force_encoding('UTF-8')
219
+ end
220
+
221
+ if env['HTTP_REFERER']
222
+ env['otto.original_referer'] = env['HTTP_REFERER'].dup.force_encoding('UTF-8')
223
+ end
204
224
 
205
- # env['REMOTE_ADDR'] remains unchanged (real IP)
225
+ # env['REMOTE_ADDR'], env['HTTP_USER_AGENT'], env['HTTP_REFERER'] remain unchanged (real values)
206
226
  # No fingerprint is created when privacy is disabled
207
227
  end
208
228
  end
@@ -1,3 +1,5 @@
1
+ # lib/otto/security/middleware/rate_limit_middleware.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative '../rate_limiter'
@@ -1,3 +1,5 @@
1
+ # lib/otto/security/middleware/validation_middleware.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative '../config'
@@ -52,8 +54,21 @@ class Otto
52
54
 
53
55
  @app.call(env)
54
56
  rescue Otto::Security::ValidationError => e
57
+ # Log validation failure
58
+ Otto.structured_log(:warn, "Input validation failed",
59
+ Otto::LoggingHelpers.request_context(env).merge(
60
+ error: e.message
61
+ )
62
+ )
55
63
  validation_error_response(e.message)
56
64
  rescue Otto::Security::RequestTooLargeError => e
65
+ # Log request size violation
66
+ Otto.structured_log(:warn, "Request too large",
67
+ Otto::LoggingHelpers.request_context(env).merge(
68
+ error: e.message,
69
+ content_length: request.env['CONTENT_LENGTH']
70
+ )
71
+ )
57
72
  request_too_large_response(e.message)
58
73
  end
59
74
  end
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/security/rate_limiter.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require 'json'
6
6
 
@@ -1,7 +1,7 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/security/rate_limiting.rb
4
2
  #
3
+ # frozen_string_literal: true
4
+ #
5
5
  # Index file for rate limiting components
6
6
  # Provides backward compatibility for existing rate limiting usage
7
7
 
@@ -1,7 +1,7 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/security/validator.rb
4
2
  #
3
+ # frozen_string_literal: true
4
+ #
5
5
  # Index file for validation middleware
6
6
  # Provides backward compatibility for existing validation usage
7
7
 
data/lib/otto/security.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  # lib/otto/security.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  require_relative 'security/authentication/strategy_result'
6
+ require_relative 'security/authorization_error'
4
7
  require_relative 'security/config'
5
8
  require_relative 'security/configurator'
6
9
  require_relative 'security/middleware/csrf_middleware'
data/lib/otto/static.rb CHANGED
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/static.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
6
  # Static response utilities for common HTTP responses
data/lib/otto/utils.rb CHANGED
@@ -1,12 +1,37 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/utils.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
6
  # Utility methods for common operations and helpers
7
7
  module Utils
8
8
  extend self
9
9
 
10
+ # @return [Time] Current time in UTC
11
+ def now
12
+ Time.now.utc
13
+ end
14
+
15
+ # Returns the current time in microseconds.
16
+ # This is used to measure the duration of Database commands.
17
+ #
18
+ # Alias: now_in_microseconds
19
+ #
20
+ # @return [Integer] The current time in microseconds.
21
+ def now_in_μs
22
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
23
+ end
24
+ alias now_in_microseconds now_in_μs
25
+
26
+ # Determine if a value represents a "yes" or true value
27
+ #
28
+ # @param value [Object] The value to evaluate
29
+ # @return [Boolean] True if the value represents "yes", false otherwise
30
+ #
31
+ # Examples:
32
+ # yes?('true') # => true
33
+ # yes?('yes') # => true
34
+ # yes?('1') # => true
10
35
  def yes?(value)
11
36
  !value.to_s.empty? && %w[true yes 1].include?(value.to_s.downcase)
12
37
  end
data/lib/otto/version.rb CHANGED
@@ -1,7 +1,7 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/version.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.0.0.pre3'
6
+ VERSION = '2.0.0.pre8'
7
7
  end
data/lib/otto.rb CHANGED
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require 'json'
6
6
  require 'logger'
@@ -17,13 +17,14 @@ require_relative 'otto/static'
17
17
  require_relative 'otto/helpers'
18
18
  require_relative 'otto/response_handlers'
19
19
  require_relative 'otto/route_handlers'
20
- require_relative 'otto/locale/config'
20
+ require_relative 'otto/locale'
21
21
  require_relative 'otto/mcp'
22
22
  require_relative 'otto/core'
23
23
  require_relative 'otto/privacy'
24
24
  require_relative 'otto/security'
25
25
  require_relative 'otto/utils'
26
26
  require_relative 'otto/version'
27
+ require_relative 'otto/logging_helpers'
27
28
 
28
29
  # Otto is a simple Rack router that allows you to define routes in a file
29
30
  # with built-in security features including CSRF protection, input validation,
@@ -65,7 +66,8 @@ class Otto
65
66
 
66
67
  attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option,
67
68
  :static_route, :security_config, :locale_config, :auth_config,
68
- :route_handler_factory, :mcp_server, :security, :middleware
69
+ :route_handler_factory, :mcp_server, :security, :middleware,
70
+ :error_handlers
69
71
  attr_accessor :not_found, :server_error
70
72
 
71
73
  def initialize(path = nil, opts = {})
@@ -77,6 +79,10 @@ class Otto
77
79
  load(path) unless path.nil?
78
80
  super()
79
81
 
82
+ # Auto-register AuthorizationError for 403 Forbidden responses
83
+ # This allows Logic classes to raise AuthorizationError for resource-level access control
84
+ register_error_handler(Otto::Security::AuthorizationError, status: 403, log_level: :warn)
85
+
80
86
  # Build the middleware app once after all initialization is complete
81
87
  build_app!
82
88
 
@@ -106,12 +112,35 @@ class Otto
106
112
  end
107
113
  end
108
114
 
115
+ # Track request timing for lifecycle hooks
116
+ start_time = Otto::Utils.now_in_μs
117
+ request = Rack::Request.new(env)
118
+ response_raw = nil
119
+
109
120
  begin
110
121
  # Use pre-built middleware app (built once at initialization)
111
- @app.call(env)
122
+ response_raw = @app.call(env)
112
123
  rescue StandardError => e
113
- handle_error(e, env)
124
+ response_raw = handle_error(e, env)
125
+ ensure
126
+ # Execute request completion hooks if any are registered
127
+ unless @request_complete_callbacks.empty?
128
+ begin
129
+ duration = Otto::Utils.now_in_μs - start_time
130
+ # Wrap response tuple in Rack::Response for developer-friendly API
131
+ # Otto's hook API should provide nice abstractions like Rack::Request/Response
132
+ response = Rack::Response.new(response_raw[2], response_raw[0], response_raw[1])
133
+ @request_complete_callbacks.each do |callback|
134
+ callback.call(request, response, duration)
135
+ end
136
+ rescue StandardError => e
137
+ Otto.logger.error "[Otto] Request completion hook error: #{e.message}"
138
+ Otto.logger.debug "[Otto] Hook error backtrace: #{e.backtrace.join("\n")}" if Otto.debug
139
+ end
140
+ end
114
141
  end
142
+
143
+ response_raw
115
144
  end
116
145
 
117
146
  # Builds the middleware application chain
@@ -145,7 +174,7 @@ class Otto
145
174
  # middleware_stack=), the @app instance variable could be swapped
146
175
  # mid-request in a multi-threaded environment.
147
176
 
148
- build_app! if @app # Rebuild app if already initialized
177
+ build_app! if @app # Rebuild app if already initialized
149
178
  end
150
179
 
151
180
  # Compatibility method for existing tests
@@ -157,7 +186,7 @@ class Otto
157
186
  def middleware_stack=(stack)
158
187
  @middleware.clear!
159
188
  Array(stack).each { |middleware| @middleware.add(middleware) }
160
- build_app! if @app # Rebuild app if already initialized
189
+ build_app! if @app # Rebuild app if already initialized
161
190
  end
162
191
 
163
192
  # Compatibility method for middleware detection
@@ -301,14 +330,73 @@ class Otto
301
330
  # @param strategy [Otto::Security::Authentication::AuthStrategy] Strategy instance
302
331
  # @example
303
332
  # otto.add_auth_strategy('custom', MyCustomStrategy.new)
333
+ # Add an authentication strategy with a registered name
334
+ #
335
+ # This is the primary public API for registering authentication strategies.
336
+ # The name you provide here will be available as `strategy_result.strategy_name`
337
+ # in your application code, making it easy to identify which strategy authenticated
338
+ # the current request.
339
+ #
340
+ # Also available via Otto::Security::Configurator for consolidated security config.
341
+ #
342
+ # @param name [String, Symbol] Strategy name (e.g., 'session', 'api_key', 'jwt')
343
+ # @param strategy [AuthStrategy] Strategy instance
344
+ # @example
345
+ # otto.add_auth_strategy('session', SessionStrategy.new(session_key: 'user_id'))
346
+ # otto.add_auth_strategy('api_key', APIKeyStrategy.new)
347
+ # @raise [ArgumentError] if strategy name already registered
304
348
  def add_auth_strategy(name, strategy)
305
349
  ensure_not_frozen!
306
350
  # Ensure auth_config is initialized (handles edge case where it might be nil)
307
351
  @auth_config = { auth_strategies: {}, default_auth_strategy: 'noauth' } if @auth_config.nil?
308
352
 
353
+ # Strict mode: Detect strategy name collisions
354
+ if @auth_config[:auth_strategies].key?(name)
355
+ raise ArgumentError, "Authentication strategy '#{name}' is already registered"
356
+ end
357
+
309
358
  @auth_config[:auth_strategies][name] = strategy
310
359
  end
311
360
 
361
+ # Register an error handler for expected business logic errors
362
+ #
363
+ # This allows you to handle known error conditions (like missing resources,
364
+ # expired data, rate limits) without logging them as unhandled 500 errors.
365
+ #
366
+ # @param error_class [Class, String] The exception class or class name to handle
367
+ # @param status [Integer] HTTP status code to return (default: 500)
368
+ # @param log_level [Symbol] Log level for expected errors (:info, :warn, :error)
369
+ # @param handler [Proc] Optional block to customize error response
370
+ #
371
+ # @example Basic usage with status code
372
+ # otto.register_error_handler(Onetime::MissingSecret, status: 404, log_level: :info)
373
+ # otto.register_error_handler(Onetime::SecretExpired, status: 410, log_level: :info)
374
+ #
375
+ # @example With custom response handler
376
+ # otto.register_error_handler(Onetime::RateLimited, status: 429, log_level: :warn) do |error, req|
377
+ # {
378
+ # error: 'Rate limit exceeded',
379
+ # retry_after: error.retry_after,
380
+ # message: error.message
381
+ # }
382
+ # end
383
+ #
384
+ # @example Using string class names (for lazy loading)
385
+ # otto.register_error_handler('Onetime::MissingSecret', status: 404, log_level: :info)
386
+ #
387
+ def register_error_handler(error_class, status: 500, log_level: :info, &handler)
388
+ ensure_not_frozen!
389
+
390
+ # Normalize error class to string for consistent lookup
391
+ error_class_name = error_class.is_a?(String) ? error_class : error_class.name
392
+
393
+ @error_handlers[error_class_name] = {
394
+ status: status,
395
+ log_level: log_level,
396
+ handler: handler
397
+ }
398
+ end
399
+
312
400
  # Disable IP privacy to access original IP addresses
313
401
  #
314
402
  # IMPORTANT: By default, Otto masks public IP addresses for privacy.
@@ -327,7 +415,6 @@ class Otto
327
415
  @security_config.ip_privacy_config.disable!
328
416
  end
329
417
 
330
-
331
418
  # Enable full IP privacy (mask ALL IPs including private/localhost)
332
419
  #
333
420
  # By default, Otto exempts private and localhost IPs from masking for
@@ -346,6 +433,56 @@ class Otto
346
433
  @security_config.ip_privacy_config.mask_private_ips = true
347
434
  end
348
435
 
436
+ # Register a callback to be executed after each request completes
437
+ #
438
+ # Instance-level request completion callbacks allow each Otto instance
439
+ # to have its own isolated set of callbacks, preventing duplicate
440
+ # invocations in multi-app architectures (e.g., Rack::URLMap).
441
+ #
442
+ # The callback receives three arguments:
443
+ # - request: Rack::Request object
444
+ # - response: Rack::Response object (wrapping the response tuple)
445
+ # - duration: Request processing duration in microseconds
446
+ #
447
+ # @example Basic usage
448
+ # otto = Otto.new(routes_file)
449
+ # otto.on_request_complete do |req, res, duration|
450
+ # logger.info "Request completed", path: req.path, duration: duration
451
+ # end
452
+ #
453
+ # @example Multi-app architecture
454
+ # # App 1: Core Web Application
455
+ # core_router = Otto.new
456
+ # core_router.on_request_complete do |req, res, duration|
457
+ # logger.info "Core app request", path: req.path
458
+ # end
459
+ #
460
+ # # App 2: API Application
461
+ # api_router = Otto.new
462
+ # api_router.on_request_complete do |req, res, duration|
463
+ # logger.info "API request", path: req.path
464
+ # end
465
+ #
466
+ # # Each callback only fires for its respective Otto instance
467
+ #
468
+ # @yield [request, response, duration] Block to execute after each request
469
+ # @yieldparam request [Rack::Request] The request object
470
+ # @yieldparam response [Rack::Response] The response object
471
+ # @yieldparam duration [Integer] Duration in microseconds
472
+ # @return [self] Returns self for method chaining
473
+ # @raise [FrozenError] if called after configuration is frozen
474
+ def on_request_complete(&block)
475
+ ensure_not_frozen!
476
+ @request_complete_callbacks << block if block_given?
477
+ self
478
+ end
479
+
480
+ # Get registered request completion callbacks (for internal use)
481
+ #
482
+ # @api private
483
+ # @return [Array<Proc>] Array of registered callback blocks
484
+ attr_reader :request_complete_callbacks
485
+
349
486
  # Configure IP privacy settings
350
487
  #
351
488
  # Privacy is enabled by default. Use this method to customize privacy
@@ -415,7 +552,9 @@ class Otto
415
552
  # Initialize @auth_config first so it can be shared with the configurator
416
553
  @auth_config = { auth_strategies: {}, default_auth_strategy: 'noauth' }
417
554
  @security = Otto::Security::Configurator.new(@security_config, @middleware, @auth_config)
418
- @app = nil # Pre-built middleware app (built after initialization)
555
+ @app = nil # Pre-built middleware app (built after initialization)
556
+ @request_complete_callbacks = [] # Instance-level request completion callbacks
557
+ @error_handlers = {} # Registered error handlers for expected errors
419
558
 
420
559
  # Add IP Privacy middleware first in stack (privacy by default for public IPs)
421
560
  # Private/localhost IPs are automatically exempted from masking
@@ -449,6 +588,29 @@ class Otto
449
588
 
450
589
  class << self
451
590
  attr_accessor :debug, :logger # rubocop:disable ThreadSafety/ClassAndModuleAttributes
591
+
592
+ # Helper method for structured logging that works with both standard Logger and structured loggers
593
+ def structured_log(level, message, data = {})
594
+ return unless logger
595
+
596
+ # Skip debug logging when Otto.debug is false
597
+ return if level == :debug && !debug
598
+
599
+ # Sanitize backtrace if present
600
+ if data.is_a?(Hash) && data[:backtrace].is_a?(Array)
601
+ data = data.dup
602
+ data[:backtrace] = Otto::LoggingHelpers.sanitize_backtrace(data[:backtrace])
603
+ end
604
+
605
+ # Try structured logging first (SemanticLogger, etc.)
606
+ if logger.respond_to?(level) && logger.method(level).arity > 1
607
+ logger.send(level, message, data)
608
+ else
609
+ # Fallback to standard logger with formatted string
610
+ formatted_data = data.empty? ? '' : " -- #{data.inspect}"
611
+ logger.send(level, "[Otto] #{message}#{formatted_data}")
612
+ end
613
+ end
452
614
  end
453
615
 
454
616
  # Class methods for Otto framework providing singleton access and configuration
@@ -471,7 +633,7 @@ class Otto
471
633
  end
472
634
 
473
635
  def env? *guesses
474
- !guesses.flatten.select { |n| ENV['RACK_ENV'].to_s == n.to_s }.empty?
636
+ guesses.flatten.any? { |n| ENV['RACK_ENV'].to_s == n.to_s }
475
637
  end
476
638
 
477
639
  # Test-only method to unfreeze Otto configuration
@@ -488,9 +650,7 @@ class Otto
488
650
  # @raise [RuntimeError] if RSpec is not defined (not in test environment)
489
651
  # @api private
490
652
  def unfreeze_for_testing(otto)
491
- unless defined?(RSpec)
492
- raise 'Otto.unfreeze_for_testing is only available in RSpec test environment'
493
- end
653
+ raise 'Otto.unfreeze_for_testing is only available in RSpec test environment' unless defined?(RSpec)
494
654
 
495
655
  otto.instance_variable_set(:@configuration_frozen, false)
496
656
  otto
data/otto.gemspec CHANGED
@@ -10,21 +10,25 @@ Gem::Specification.new do |spec|
10
10
  spec.email = 'gems@solutious.com'
11
11
  spec.authors = ['Delano Mandelbaum']
12
12
  spec.license = 'MIT'
13
- spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
13
+ spec.files = if File.directory?('.git') && system('git --version > /dev/null 2>&1')
14
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
15
+ else
16
+ Dir['**/*'].select { |f| File.file?(f) }.reject { |f| f.match(%r{^(test|spec|features)/}) }
17
+ end
14
18
  spec.homepage = 'https://github.com/delano/otto'
15
19
  spec.require_paths = ['lib']
16
20
 
17
21
  spec.required_ruby_version = ['>= 3.2', '< 4.0']
18
22
 
19
- spec.add_dependency 'ipaddr', '~> 1', '< 2.0'
20
23
  spec.add_dependency 'concurrent-ruby', '~> 1.3', '< 2.0'
24
+ spec.add_dependency 'ipaddr', '~> 1', '< 2.0'
21
25
 
22
26
  # Logger is not part of the default gems as of Ruby 3.5.0
23
27
  spec.add_dependency 'logger', '~> 1', '< 2.0'
24
28
 
25
29
  spec.add_dependency 'rack', '~> 3.1', '< 4.0'
26
30
  spec.add_dependency 'rack-parser', '~> 0.7'
27
- spec.add_dependency 'rexml', '>= 3.3.6'
31
+ spec.add_dependency 'rexml', '~> 3.4'
28
32
 
29
33
  # Security dependencies
30
34
  spec.add_dependency 'facets', '~> 3.1'