otto 2.0.0.pre3 → 2.0.0.pre7

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 (103) 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 +146 -0
  5. data/.gitignore +4 -0
  6. data/.pre-commit-config.yaml +2 -2
  7. data/.reek.yml +99 -0
  8. data/CHANGELOG.rst +90 -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/changelog.d/20251103_235431_delano_86_improve_error_logging.rst +15 -0
  14. data/changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst +37 -0
  15. data/examples/advanced_routes/README.md +137 -20
  16. data/examples/authentication_strategies/README.md +212 -19
  17. data/examples/backtrace_sanitization_demo.rb +86 -0
  18. data/examples/basic/README.md +61 -10
  19. data/examples/error_handler_registration.rb +136 -0
  20. data/examples/logging_improvements.rb +76 -0
  21. data/examples/mcp_demo/README.md +187 -27
  22. data/examples/security_features/README.md +249 -30
  23. data/examples/simple_geo_resolver.rb +107 -0
  24. data/lib/otto/core/configuration.rb +15 -20
  25. data/lib/otto/core/error_handler.rb +138 -8
  26. data/lib/otto/core/file_safety.rb +2 -2
  27. data/lib/otto/core/freezable.rb +2 -2
  28. data/lib/otto/core/middleware_stack.rb +2 -2
  29. data/lib/otto/core/router.rb +61 -8
  30. data/lib/otto/core/uri_generator.rb +2 -2
  31. data/lib/otto/core.rb +2 -0
  32. data/lib/otto/design_system.rb +2 -2
  33. data/lib/otto/env_keys.rb +61 -12
  34. data/lib/otto/helpers/base.rb +2 -2
  35. data/lib/otto/helpers/request.rb +8 -3
  36. data/lib/otto/helpers/response.rb +2 -2
  37. data/lib/otto/helpers/validation.rb +2 -2
  38. data/lib/otto/helpers.rb +2 -0
  39. data/lib/otto/locale/config.rb +2 -2
  40. data/lib/otto/locale/middleware.rb +160 -0
  41. data/lib/otto/locale.rb +10 -0
  42. data/lib/otto/logging_helpers.rb +273 -0
  43. data/lib/otto/mcp/auth/token.rb +2 -2
  44. data/lib/otto/mcp/protocol.rb +2 -2
  45. data/lib/otto/mcp/rate_limiting.rb +2 -2
  46. data/lib/otto/mcp/registry.rb +2 -2
  47. data/lib/otto/mcp/route_parser.rb +2 -2
  48. data/lib/otto/mcp/schema_validation.rb +2 -2
  49. data/lib/otto/mcp/server.rb +2 -2
  50. data/lib/otto/mcp.rb +2 -0
  51. data/lib/otto/privacy/config.rb +2 -0
  52. data/lib/otto/privacy/geo_resolver.rb +199 -29
  53. data/lib/otto/privacy/ip_privacy.rb +2 -0
  54. data/lib/otto/privacy/redacted_fingerprint.rb +18 -8
  55. data/lib/otto/privacy.rb +2 -0
  56. data/lib/otto/response_handlers/auto.rb +2 -0
  57. data/lib/otto/response_handlers/base.rb +2 -0
  58. data/lib/otto/response_handlers/default.rb +2 -0
  59. data/lib/otto/response_handlers/factory.rb +2 -0
  60. data/lib/otto/response_handlers/json.rb +2 -0
  61. data/lib/otto/response_handlers/redirect.rb +2 -0
  62. data/lib/otto/response_handlers/view.rb +2 -0
  63. data/lib/otto/response_handlers.rb +2 -2
  64. data/lib/otto/route.rb +4 -4
  65. data/lib/otto/route_definition.rb +42 -15
  66. data/lib/otto/route_handlers/base.rb +2 -0
  67. data/lib/otto/route_handlers/class_method.rb +18 -25
  68. data/lib/otto/route_handlers/factory.rb +2 -2
  69. data/lib/otto/route_handlers/instance_method.rb +8 -5
  70. data/lib/otto/route_handlers/lambda.rb +8 -20
  71. data/lib/otto/route_handlers/logic_class.rb +23 -6
  72. data/lib/otto/route_handlers.rb +2 -2
  73. data/lib/otto/security/authentication/auth_failure.rb +2 -2
  74. data/lib/otto/security/authentication/auth_strategy.rb +11 -4
  75. data/lib/otto/security/authentication/route_auth_wrapper.rb +230 -78
  76. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
  77. data/lib/otto/security/authentication/strategies/noauth_strategy.rb +2 -0
  78. data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
  79. data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
  80. data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
  81. data/lib/otto/security/authentication/strategy_result.rb +6 -5
  82. data/lib/otto/security/authentication.rb +2 -2
  83. data/lib/otto/security/authorization_error.rb +73 -0
  84. data/lib/otto/security/config.rb +2 -2
  85. data/lib/otto/security/configurator.rb +17 -2
  86. data/lib/otto/security/csrf.rb +2 -2
  87. data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
  88. data/lib/otto/security/middleware/ip_privacy_middleware.rb +31 -11
  89. data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
  90. data/lib/otto/security/middleware/validation_middleware.rb +15 -0
  91. data/lib/otto/security/rate_limiter.rb +2 -2
  92. data/lib/otto/security/rate_limiting.rb +2 -2
  93. data/lib/otto/security/validator.rb +2 -2
  94. data/lib/otto/security.rb +3 -0
  95. data/lib/otto/static.rb +2 -2
  96. data/lib/otto/utils.rb +27 -2
  97. data/lib/otto/version.rb +3 -3
  98. data/lib/otto.rb +174 -14
  99. data/otto.gemspec +7 -3
  100. metadata +24 -15
  101. data/benchmark_middleware_wrap.rb +0 -163
  102. data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +0 -36
  103. data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +0 -5
@@ -1,4 +1,6 @@
1
1
  # lib/otto/privacy/geo_resolver.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  require 'ipaddr'
4
6
 
@@ -7,53 +9,221 @@ class Otto
7
9
  # Lightweight geo-location resolution for IP addresses
8
10
  #
9
11
  # Provides country-level geo-location without requiring external
10
- # databases or API calls. Uses CloudFlare headers when available,
11
- # with fallback to basic IP range detection.
12
+ # databases or API calls. Supports headers from major CDN/infrastructure
13
+ # providers (Cloudflare, AWS CloudFront, Fastly, Akamai, Azure) with
14
+ # fallback to basic IP range detection.
12
15
  #
13
- # @example Resolve country from CloudFlare header
16
+ # Supported CDN/Infrastructure Headers:
17
+ # - Cloudflare: CF-IPCountry
18
+ # - AWS CloudFront: CloudFront-Viewer-Country
19
+ # - Fastly: Fastly-Client-IP-Country
20
+ # - Akamai: X-Akamai-Edgescape (country_code=XX format)
21
+ # - Azure Front Door: X-Azure-ClientIP-Country
22
+ # - Semi-standard: X-Geo-Country, X-Country-Code, Country-Code
23
+ #
24
+ # @example Resolve country from Cloudflare header
14
25
  # env = { 'HTTP_CF_IPCOUNTRY' => 'US' }
15
26
  # GeoResolver.resolve('1.2.3.4', env)
16
27
  # # => 'US'
17
28
  #
18
- # @example Resolve without CloudFlare
19
- # GeoResolver.resolve('9.9.9.9', {})
20
- # # => 'CH' (Quad9 in Switzerland)
29
+ # @example Resolve from AWS CloudFront
30
+ # env = { 'HTTP_CLOUDFRONT_VIEWER_COUNTRY' => 'GB' }
31
+ # GeoResolver.resolve('1.2.3.4', env)
32
+ # # => 'GB'
33
+ #
34
+ # @example Resolve without CDN headers
35
+ # GeoResolver.resolve('8.8.8.8', {})
36
+ # # => 'US' (Google DNS via range detection)
37
+ #
38
+ # @example Using a custom resolver (MaxMind)
39
+ # GeoResolver.custom_resolver = ->(ip, env) {
40
+ # reader = MaxMind::DB.new('GeoLite2-Country.mmdb')
41
+ # result = reader.get(ip)
42
+ # result&.dig('country', 'iso_code')
43
+ # }
44
+ # GeoResolver.resolve('1.2.3.4', {}) # Uses custom resolver
45
+ #
46
+ # @example Extending via subclass
47
+ # class MyGeoResolver < Otto::Privacy::GeoResolver
48
+ # def self.detect_by_range(ip)
49
+ # # Custom logic here
50
+ # super # Fall back to parent
51
+ # end
52
+ # end
53
+ #
54
+ #
55
+ # Resolution flow
56
+ #
57
+ # Request → Has familiar HTTP Header?
58
+ # ├─ Yes → Return country (Cloudflare, AWS, etc.)
59
+ # └─ No → Custom Resolver?
60
+ # ├─ Configured → Call & validate
61
+ # │ ├─ Valid → Return country
62
+ # │ └─ Invalid/Error → Continue
63
+ # └─ Not configured → Built-in range detection
64
+ # └─ Unknown ('**')
21
65
  #
22
66
  class GeoResolver
23
- # Unknown country code (ISO 3166-1 alpha-2)
24
- UNKNOWN = 'XX'
67
+ # Unknown country code (not ISO 3166-1 alpha-2, intentionally distinct)
68
+ UNKNOWN = '**'
69
+
70
+ # Custom resolver for extending geo-location capabilities
71
+ # Can be set to a proc/lambda or a class responding to .call(ip, env)
72
+ #
73
+ # @example Using a proc
74
+ # GeoResolver.custom_resolver = ->(ip, env) {
75
+ # return nil # Return nil to continue with built-in resolution
76
+ # # or return 'US' to provide custom country code
77
+ # }
78
+ #
79
+ # @example Using MaxMind
80
+ # GeoResolver.custom_resolver = ->(ip, env) {
81
+ # reader = MaxMind::DB.new('path/to/GeoLite2-Country.mmdb')
82
+ # result = reader.get(ip)
83
+ # result&.dig('country', 'iso_code')
84
+ # }
85
+ #
86
+ # Thread Safety Model:
87
+ # This follows Ruby's standard configuration pattern:
88
+ # - Set ONCE at boot time (single-threaded initialization)
89
+ # - Read many times during requests (multi-threaded reads)
90
+ # - No synchronization needed for this access pattern
91
+ #
92
+ # Runtime resolver switching is NOT supported. Changing the resolver
93
+ # while processing requests creates race conditions (write vs read).
94
+ # This is intentional - resolver configuration is boot-time only.
95
+ @custom_resolver = nil
96
+
97
+ class << self
98
+ attr_accessor :custom_resolver
99
+
100
+ # Set a custom resolver for geo-location
101
+ #
102
+ # MUST be called during single-threaded initialization (before
103
+ # accepting requests). Runtime changes while serving requests
104
+ # will cause race conditions.
105
+ #
106
+ # @param resolver [Proc, #call] A proc or callable object that takes (ip, env)
107
+ # and returns a country code string or nil
108
+ # @raise [ArgumentError] if resolver doesn't respond to :call
109
+ def custom_resolver=(resolver)
110
+ unless resolver.nil? || resolver.respond_to?(:call)
111
+ raise ArgumentError, 'Custom resolver must respond to :call'
112
+ end
113
+ @custom_resolver = resolver
114
+ end
115
+ end
25
116
 
26
117
  # Resolve country code for an IP address
27
118
  #
28
119
  # Resolution priority:
29
- # 1. CloudFlare CF-IPCountry header (most reliable)
120
+ # 1. CDN/infrastructure provider headers (Cloudflare, AWS, Fastly, etc.)
30
121
  # 2. Basic IP range detection for major countries/providers
31
- # 3. Return 'XX' for unknown
122
+ # 3. Return '**' for unknown
32
123
  #
33
124
  # @param ip [String] IP address to resolve
34
- # @param env [Hash] Rack environment (may contain CF headers)
35
- # @return [String] ISO 3166-1 alpha-2 country code or 'XX'
125
+ # @param env [Hash] Rack environment (may contain geo headers)
126
+ # @return [String] ISO 3166-1 alpha-2 country code or '**'
36
127
  def self.resolve(ip, env = {})
37
128
  return UNKNOWN if ip.nil? || ip.empty?
38
129
 
39
- # Priority 1: CloudFlare header (free, accurate, no database)
40
- cf_country = env['HTTP_CF_IPCOUNTRY']
41
- return cf_country if cf_country && valid_country_code?(cf_country)
130
+ # Check CDN/infrastructure headers in priority order
131
+ # Priority based on reliability and deployment frequency
132
+ country = check_geo_headers(env)
133
+ return country if country
134
+
135
+ # Try custom resolver if configured
136
+ if @custom_resolver
137
+ begin
138
+ country = @custom_resolver.call(ip, env)
139
+ return country if country && valid_country_code?(country)
140
+ rescue StandardError => e
141
+ # Log error but don't crash - fall through to built-in detection
142
+ warn "GeoResolver custom resolver error: #{e.message}" if $DEBUG
143
+ end
144
+ end
42
145
 
43
- # Priority 2: Basic range detection
146
+ # Fallback: Basic range detection
44
147
  detect_by_range(ip)
45
148
  rescue IPAddr::InvalidAddressError
46
149
  UNKNOWN
47
150
  end
48
151
 
152
+ # Check CDN/infrastructure provider geo headers
153
+ #
154
+ # Headers are checked in order of reliability and deployment frequency:
155
+ # 1. Cloudflare (CF-IPCountry) - Most widely deployed
156
+ # 2. AWS CloudFront (CloudFront-Viewer-Country)
157
+ # 3. Fastly (Fastly-Client-IP-Country)
158
+ # 4. Akamai (X-Akamai-Edgescape) - Complex format, extract country
159
+ # 5. Azure Front Door (X-Azure-ClientIP-Country)
160
+ # 6. Semi-standard headers (X-Geo-Country, X-Country-Code, Country-Code)
161
+ #
162
+ # @param env [Hash] Rack environment
163
+ # @return [String, nil] ISO 3166-1 alpha-2 country code or nil
164
+ # @api private
165
+ def self.check_geo_headers(env)
166
+ # Cloudflare (most common)
167
+ country = env['HTTP_CF_IPCOUNTRY']
168
+ return country if valid_country_code?(country)
169
+
170
+ # AWS CloudFront
171
+ country = env['HTTP_CLOUDFRONT_VIEWER_COUNTRY']
172
+ return country if valid_country_code?(country)
173
+
174
+ # Fastly
175
+ country = env['HTTP_FASTLY_CLIENT_IP_COUNTRY']
176
+ return country if valid_country_code?(country)
177
+
178
+ # Akamai Edgescape (format: country_code=US,region_code=CA,...)
179
+ if (edgescape = env['HTTP_X_AKAMAI_EDGESCAPE'])
180
+ country = extract_akamai_country(edgescape)
181
+ return country if valid_country_code?(country)
182
+ end
183
+
184
+ # Azure Front Door
185
+ country = env['HTTP_X_AZURE_CLIENTIP_COUNTRY']
186
+ return country if valid_country_code?(country)
187
+
188
+ # Semi-standard headers (least reliable, check last)
189
+ country = env['HTTP_X_GEO_COUNTRY']
190
+ return country if valid_country_code?(country)
191
+
192
+ country = env['HTTP_X_COUNTRY_CODE']
193
+ return country if valid_country_code?(country)
194
+
195
+ country = env['HTTP_COUNTRY_CODE']
196
+ return country if valid_country_code?(country)
197
+
198
+ nil
199
+ end
200
+ private_class_method :check_geo_headers
201
+
202
+ # Extract country code from Akamai Edgescape header
203
+ #
204
+ # Edgescape format: "country_code=US,region_code=CA,city=LOSANGELES,..."
205
+ #
206
+ # @param edgescape [String] Akamai Edgescape header value
207
+ # @return [String, nil] Country code or nil
208
+ # @api private
209
+ def self.extract_akamai_country(edgescape)
210
+ return nil unless edgescape.is_a?(String)
211
+
212
+ # Extract country_code=XX (must be exactly 2 uppercase letters, bounded)
213
+ # Use word boundary or comma to ensure we don't match partial strings like "INVALID"
214
+ match = edgescape.match(/country_code=([A-Z]{2})(?:,|\z)/)
215
+ match ? match[1] : nil
216
+ end
217
+ private_class_method :extract_akamai_country
218
+
49
219
  # Detect country by IP range (basic implementation)
50
220
  #
51
221
  # Detects major cloud providers and well-known IP ranges.
52
222
  # This is intentionally limited - for comprehensive geo-location,
53
- # use CloudFlare or a dedicated GeoIP database.
223
+ # use CDN headers or configure a custom resolver.
54
224
  #
55
225
  # @param ip [String] IP address
56
- # @return [String] Country code or 'XX'
226
+ # @return [String] Country code or '**'
57
227
  # @api private
58
228
  def self.detect_by_range(ip)
59
229
  addr = IPAddr.new(ip)
@@ -70,18 +240,8 @@ class Otto
70
240
  end
71
241
  private_class_method :detect_by_range
72
242
 
73
- # Validate country code format
74
- #
75
- # @param code [String] Country code to validate
76
- # @return [Boolean] true if valid ISO 3166-1 alpha-2 code
77
- # @api private
78
- def self.valid_country_code?(code)
79
- code.is_a?(String) && code.length == 2 && code.match?(/^[A-Z]{2}$/)
80
- end
81
- private_class_method :valid_country_code?
82
-
83
243
  # Known IP ranges for major providers (limited set for basic detection)
84
- # For comprehensive geo-location, use CloudFlare or GeoIP database
244
+ # For comprehensive geo-location, use CDN headers or custom resolver
85
245
  KNOWN_RANGES = {
86
246
  # Google Public DNS
87
247
  IPAddr.new('8.8.8.0/24') => 'US',
@@ -110,6 +270,16 @@ class Otto
110
270
  IPAddr.new('208.67.222.0/24') => 'US',
111
271
  IPAddr.new('208.67.220.0/24') => 'US',
112
272
  }.freeze
273
+
274
+ # Validate country code format
275
+ #
276
+ # @param code [String] Country code to validate
277
+ # @return [Boolean] true if valid ISO 3166-1 alpha-2 code
278
+ # @api private
279
+ def self.valid_country_code?(code)
280
+ code.is_a?(String) && code.length == 2 && code.match?(/^[A-Z]{2}$/)
281
+ end
282
+ private_class_method :valid_country_code?
113
283
  end
114
284
  end
115
285
  end
@@ -1,4 +1,6 @@
1
1
  # lib/otto/privacy/ip_privacy.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  require 'ipaddr'
4
6
  require 'digest'
@@ -1,4 +1,6 @@
1
1
  # lib/otto/privacy/redacted_fingerprint.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  require 'securerandom'
4
6
  require 'time'
@@ -88,21 +90,29 @@ class Otto
88
90
 
89
91
  private
90
92
 
91
- # Anonymize user agent string by removing version numbers
93
+ # Anonymize user agent string by removing version numbers and build identifiers
92
94
  #
93
- # Removes specific version numbers (X.X.X pattern) to reduce
94
- # fingerprinting granularity while maintaining browser/OS info.
95
+ # Removes specific version numbers (*.*.* pattern) and build identifiers
96
+ # (e.g., Build/MRA58N) to reduce fingerprinting granularity while maintaining
97
+ # browser/OS info.
95
98
  #
96
99
  # @param ua [String, nil] User agent string
97
100
  # @return [String, nil] Anonymized user agent or nil
98
101
  def anonymize_user_agent(ua)
99
102
  return nil if ua.nil? || ua.empty?
100
103
 
101
- # Remove version patterns (X.X.X.X, X.X.X, X.X)
102
- anonymized = ua
103
- .gsub(/\d+\.\d+\.\d+\.\d+/, 'X.X.X.X')
104
- .gsub(/\d+\.\d+\.\d+/, 'X.X.X')
105
- .gsub(/\d+\.\d+/, 'X.X')
104
+ # Remove build identifiers (e.g., Build/MRA58N, Build/MPJ24.139-64)
105
+ # This must run BEFORE version stripping to avoid partial matches.
106
+ # If we strip versions first, Build/MPJ24.139-64 becomes Build/MPJ*.*-64,
107
+ # and the regex won't match properly (asterisks not in [\w.-] class).
108
+ anonymized = ua.gsub(/Build\/[\w.-]+/, 'Build/*')
109
+
110
+ # Remove version patterns (*.*.*.*, *.*.*, *.*)
111
+ # Support both dot and underscore separators (e.g., 10.15.7 and 10_15_7)
112
+ anonymized = anonymized
113
+ .gsub(/\d+[._]\d+[._]\d+[._]\d+/, '*.*.*.*')
114
+ .gsub(/\d+[._]\d+[._]\d+/, '*.*.*')
115
+ .gsub(/\d+[._]\d+/, '*.*')
106
116
 
107
117
  # Truncate if too long (prevent DoS via huge UA strings)
108
118
  anonymized.length > 500 ? anonymized[0..499] : anonymized
data/lib/otto/privacy.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # lib/otto/privacy.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative 'privacy/config'
@@ -1,3 +1,5 @@
1
+ # lib/otto/response_handlers/auto.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative 'base'
@@ -1,3 +1,5 @@
1
+ # lib/otto/response_handlers/base.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  class Otto
@@ -1,3 +1,5 @@
1
+ # lib/otto/response_handlers/default.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative 'base'
@@ -1,3 +1,5 @@
1
+ # lib/otto/response_handlers/factory.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative 'json'
@@ -1,3 +1,5 @@
1
+ # lib/otto/response_handlers/json.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative 'base'
@@ -1,3 +1,5 @@
1
+ # lib/otto/response_handlers/redirect.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative 'base'
@@ -1,3 +1,5 @@
1
+ # lib/otto/response_handlers/view.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative 'base'
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/response_handlers.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
6
  module ResponseHandlers
data/lib/otto/route.rb CHANGED
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/route.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
6
  # Otto::Route
@@ -158,8 +158,8 @@ class Otto
158
158
  if response_type != 'default'
159
159
  context = {
160
160
  logic_instance: (kind == :instance ? inst : nil),
161
- status_code: nil,
162
- redirect_path: nil,
161
+ status_code: nil,
162
+ redirect_path: nil,
163
163
  }
164
164
 
165
165
  Otto::ResponseHandlers::HandlerFactory.handle_response(result, res, response_type, context)
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/route_definition.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
6
  # Immutable data class representing a complete route definition
@@ -73,10 +73,37 @@ class Otto
73
73
  @options.fetch(key.to_sym, default)
74
74
  end
75
75
 
76
- # Get authentication requirement
76
+ # Get authentication requirement (backward compatibility - returns first requirement)
77
77
  # @return [String, nil] The auth requirement or nil
78
78
  def auth_requirement
79
- option(:auth)
79
+ auth_requirements.first
80
+ end
81
+
82
+ # Get all authentication requirements as an array
83
+ # Supports multiple strategies: auth=session,apikey,oauth
84
+ # @return [Array<String>] Array of auth requirement strings
85
+ def auth_requirements
86
+ auth = option(:auth)
87
+ return [] unless auth
88
+
89
+ auth.split(',').map(&:strip).reject(&:empty?)
90
+ end
91
+
92
+ # Get role requirement for route-level authorization
93
+ # Supports single role or comma-separated roles (OR logic): role=admin,editor
94
+ # @return [String, nil] The role requirement or nil
95
+ def role_requirement
96
+ option(:role)
97
+ end
98
+
99
+ # Get all role requirements as an array
100
+ # Supports multiple roles with OR logic: role=admin,editor
101
+ # @return [Array<String>] Array of role requirement strings
102
+ def role_requirements
103
+ role = option(:role)
104
+ return [] unless role
105
+
106
+ role.split(',').map(&:strip).reject(&:empty?)
80
107
  end
81
108
 
82
109
  # Get response type
@@ -111,16 +138,16 @@ class Otto
111
138
  # @return [Hash]
112
139
  def to_h
113
140
  {
114
- verb: @verb,
115
- path: @path,
116
- definition: @definition,
117
- target: @target,
118
- klass_name: @klass_name,
141
+ verb: @verb,
142
+ path: @path,
143
+ definition: @definition,
144
+ target: @target,
145
+ klass_name: @klass_name,
119
146
  method_name: @method_name,
120
- kind: @kind,
121
- options: @options,
122
- pattern: @pattern,
123
- keys: @keys,
147
+ kind: @kind,
148
+ options: @options,
149
+ pattern: @pattern,
150
+ keys: @keys,
124
151
  }
125
152
  end
126
153
 
@@ -166,11 +193,11 @@ class Otto
166
193
  case target
167
194
  when /^(.+)\.(.+)$/
168
195
  # Class.method - call class method directly
169
- { klass_name: $1, method_name: $2, kind: :class }
196
+ { klass_name: ::Regexp.last_match(1), method_name: ::Regexp.last_match(2), kind: :class }
170
197
 
171
198
  when /^(.+)#(.+)$/
172
199
  # Class#method - instantiate then call instance method
173
- { klass_name: $1, method_name: $2, kind: :instance }
200
+ { klass_name: ::Regexp.last_match(1), method_name: ::Regexp.last_match(2), kind: :instance }
174
201
 
175
202
  when /^[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*$/
176
203
  # Bare class name - instantiate the class
@@ -1,4 +1,6 @@
1
1
  # lib/otto/route_handlers/base.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  require 'json'
4
6
 
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/route_handlers/class_method.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require 'json'
6
6
  require 'securerandom'
@@ -13,6 +13,7 @@ class Otto
13
13
  # Maintains backward compatibility for Controller.action patterns
14
14
  class ClassMethodHandler < BaseHandler
15
15
  def call(env, extra_params = {})
16
+ start_time = Otto::Utils.now_in_μs
16
17
  req = Rack::Request.new(env)
17
18
  res = Rack::Response.new
18
19
 
@@ -25,19 +26,22 @@ class Otto
25
26
 
26
27
  # Only handle response if response_type is not default
27
28
  if route_definition.response_type != 'default'
28
- handle_response(result, res, {
29
- class: target_class,
29
+ handle_response(result, res,
30
+ {
31
+ class: target_class,
30
32
  request: req,
31
- })
33
+ })
32
34
  end
33
35
  rescue StandardError => e
34
36
  # Check if we're being called through Otto's integrated context (vs direct handler testing)
35
37
  # In integrated context, let Otto's centralized error handler manage the response
36
38
  # In direct testing context, handle errors locally for unit testing
37
39
  if otto_instance
38
- # Log error for handler-specific context but let Otto's centralized error handler manage the response
39
- Otto.logger.error "[ClassMethodHandler] #{e.class}: #{e.message}"
40
- Otto.logger.debug "[ClassMethodHandler] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
40
+ # Store handler context in env for centralized error handler
41
+ handler_name = "#{target_class.name}##{route_definition.method_name}"
42
+ env['otto.handler'] = handler_name
43
+ env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
44
+
41
45
  raise e # Re-raise to let Otto's centralized error handler manage the response
42
46
  else
43
47
  # Direct handler testing context - handle errors locally with security improvements
@@ -51,26 +55,15 @@ class Otto
51
55
  accept_header = env['HTTP_ACCEPT'].to_s
52
56
  if accept_header.include?('application/json')
53
57
  res.headers['content-type'] = 'application/json'
54
- error_data = if Otto.env?(:dev, :development)
55
- {
56
- error: 'Internal Server Error',
57
- message: 'Server error occurred. Check logs for details.',
58
- error_id: error_id,
59
- }
60
- else
61
- {
62
- error: 'Internal Server Error',
63
- message: 'An error occurred. Please try again later.',
64
- }
65
- end
58
+ error_data = {
59
+ error: 'Internal Server Error',
60
+ message: 'Server error occurred. Check logs for details.',
61
+ error_id: error_id,
62
+ }
66
63
  res.write JSON.generate(error_data)
67
64
  else
68
65
  res.headers['content-type'] = 'text/plain'
69
- if Otto.env?(:dev, :development)
70
- res.write "Server error (ID: #{error_id}). Check logs for details."
71
- else
72
- res.write 'An error occurred. Please try again later.'
73
- end
66
+ res.write "Server error (ID: #{error_id}). Check logs for details."
74
67
  end
75
68
 
76
69
  # Add security headers if available
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/route_handlers/factory.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require_relative 'base'
6
6
  require_relative '../security/authentication/route_auth_wrapper'
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/route_handlers/instance_method.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
  require 'securerandom'
5
5
 
6
6
  require_relative 'base'
@@ -11,6 +11,7 @@ class Otto
11
11
  # Maintains backward compatibility for Controller#action patterns
12
12
  class InstanceMethodHandler < BaseHandler
13
13
  def call(env, extra_params = {})
14
+ start_time = Otto::Utils.now_in_μs
14
15
  req = Rack::Request.new(env)
15
16
  res = Rack::Response.new
16
17
 
@@ -34,9 +35,11 @@ class Otto
34
35
  # In integrated context, let Otto's centralized error handler manage the response
35
36
  # In direct testing context, handle errors locally for unit testing
36
37
  if otto_instance
37
- # Log error for handler-specific context but let the centralized error handler manage the response
38
- Otto.logger.error "[InstanceMethodHandler] #{e.class}: #{e.message}"
39
- Otto.logger.debug "[InstanceMethodHandler] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
38
+ # Store handler context in env for centralized error handler
39
+ handler_name = "#{target_class}##{route_definition.method_name}"
40
+ env['otto.handler'] = handler_name
41
+ env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
42
+
40
43
  raise e # Re-raise to let Otto's centralized error handler manage the response
41
44
  else
42
45
  # Direct handler testing context - handle errors locally with security improvements