otto 2.0.0.pre2 → 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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -3
  3. data/.github/workflows/claude-code-review.yml +29 -13
  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 +116 -45
  10. data/Gemfile +5 -2
  11. data/Gemfile.lock +70 -24
  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/docs/.gitignore +1 -0
  16. data/docs/ipaddr-encoding-quirk.md +34 -0
  17. data/docs/migrating/v2.0.0-pre2.md +11 -18
  18. data/examples/advanced_routes/README.md +137 -20
  19. data/examples/authentication_strategies/README.md +212 -19
  20. data/examples/authentication_strategies/config.ru +0 -1
  21. data/examples/backtrace_sanitization_demo.rb +86 -0
  22. data/examples/basic/README.md +61 -10
  23. data/examples/error_handler_registration.rb +136 -0
  24. data/examples/logging_improvements.rb +76 -0
  25. data/examples/mcp_demo/README.md +187 -27
  26. data/examples/security_features/README.md +249 -30
  27. data/examples/simple_geo_resolver.rb +107 -0
  28. data/lib/otto/core/configuration.rb +90 -45
  29. data/lib/otto/core/error_handler.rb +138 -8
  30. data/lib/otto/core/file_safety.rb +2 -2
  31. data/lib/otto/core/freezable.rb +93 -0
  32. data/lib/otto/core/middleware_stack.rb +25 -18
  33. data/lib/otto/core/router.rb +62 -9
  34. data/lib/otto/core/uri_generator.rb +2 -2
  35. data/lib/otto/core.rb +10 -0
  36. data/lib/otto/design_system.rb +2 -2
  37. data/lib/otto/env_keys.rb +65 -12
  38. data/lib/otto/helpers/base.rb +2 -2
  39. data/lib/otto/helpers/request.rb +85 -2
  40. data/lib/otto/helpers/response.rb +5 -5
  41. data/lib/otto/helpers/validation.rb +2 -2
  42. data/lib/otto/helpers.rb +6 -0
  43. data/lib/otto/locale/config.rb +56 -0
  44. data/lib/otto/locale/middleware.rb +160 -0
  45. data/lib/otto/locale.rb +10 -0
  46. data/lib/otto/logging_helpers.rb +273 -0
  47. data/lib/otto/mcp/auth/token.rb +2 -2
  48. data/lib/otto/mcp/protocol.rb +2 -2
  49. data/lib/otto/mcp/rate_limiting.rb +2 -2
  50. data/lib/otto/mcp/registry.rb +2 -2
  51. data/lib/otto/mcp/route_parser.rb +2 -2
  52. data/lib/otto/mcp/schema_validation.rb +2 -2
  53. data/lib/otto/mcp/server.rb +2 -2
  54. data/lib/otto/mcp.rb +5 -0
  55. data/lib/otto/privacy/config.rb +201 -0
  56. data/lib/otto/privacy/geo_resolver.rb +285 -0
  57. data/lib/otto/privacy/ip_privacy.rb +177 -0
  58. data/lib/otto/privacy/redacted_fingerprint.rb +146 -0
  59. data/lib/otto/privacy.rb +31 -0
  60. data/lib/otto/response_handlers/auto.rb +2 -0
  61. data/lib/otto/response_handlers/base.rb +2 -0
  62. data/lib/otto/response_handlers/default.rb +2 -0
  63. data/lib/otto/response_handlers/factory.rb +2 -0
  64. data/lib/otto/response_handlers/json.rb +2 -0
  65. data/lib/otto/response_handlers/redirect.rb +2 -0
  66. data/lib/otto/response_handlers/view.rb +2 -0
  67. data/lib/otto/response_handlers.rb +2 -2
  68. data/lib/otto/route.rb +4 -4
  69. data/lib/otto/route_definition.rb +42 -15
  70. data/lib/otto/route_handlers/base.rb +2 -1
  71. data/lib/otto/route_handlers/class_method.rb +18 -25
  72. data/lib/otto/route_handlers/factory.rb +18 -16
  73. data/lib/otto/route_handlers/instance_method.rb +8 -5
  74. data/lib/otto/route_handlers/lambda.rb +8 -20
  75. data/lib/otto/route_handlers/logic_class.rb +25 -8
  76. data/lib/otto/route_handlers.rb +2 -2
  77. data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +5 -5
  78. data/lib/otto/security/authentication/auth_strategy.rb +13 -6
  79. data/lib/otto/security/authentication/route_auth_wrapper.rb +304 -41
  80. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
  81. data/lib/otto/security/authentication/strategies/noauth_strategy.rb +7 -1
  82. data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
  83. data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
  84. data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
  85. data/lib/otto/security/authentication/strategy_result.rb +6 -5
  86. data/lib/otto/security/authentication.rb +5 -6
  87. data/lib/otto/security/authorization_error.rb +73 -0
  88. data/lib/otto/security/config.rb +53 -9
  89. data/lib/otto/security/configurator.rb +17 -15
  90. data/lib/otto/security/csrf.rb +2 -2
  91. data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
  92. data/lib/otto/security/middleware/ip_privacy_middleware.rb +231 -0
  93. data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
  94. data/lib/otto/security/middleware/validation_middleware.rb +15 -0
  95. data/lib/otto/security/rate_limiter.rb +2 -2
  96. data/lib/otto/security/rate_limiting.rb +2 -2
  97. data/lib/otto/security/validator.rb +2 -2
  98. data/lib/otto/security.rb +12 -0
  99. data/lib/otto/static.rb +2 -2
  100. data/lib/otto/utils.rb +27 -2
  101. data/lib/otto/version.rb +3 -3
  102. data/lib/otto.rb +344 -89
  103. data/otto.gemspec +9 -2
  104. metadata +72 -8
  105. data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/core/router.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require_relative '../mcp/route_parser'
6
6
 
@@ -36,13 +36,28 @@ class Otto
36
36
  route.otto = self
37
37
  path_clean = path.gsub(%r{/$}, '')
38
38
  @route_definitions[route.definition] = route
39
- Otto.logger.debug "route: #{route.pattern}" if Otto.debug
39
+ Otto.structured_log(:debug, "Route loaded",
40
+ {
41
+ pattern: route.pattern.source,
42
+ verb: route.verb,
43
+ definition: route.definition,
44
+ type: 'pattern'
45
+ }
46
+ ) if Otto.debug
40
47
  @routes[route.verb] ||= []
41
48
  @routes[route.verb] << route
42
49
  @routes_literal[route.verb] ||= {}
43
50
  @routes_literal[route.verb][path_clean] = route
44
51
  rescue StandardError => e
45
- Otto.logger.error "Error for route #{path}: #{e.message}"
52
+ Otto.structured_log(:error, "Route load failed",
53
+ {
54
+ path: path,
55
+ verb: verb,
56
+ definition: definition,
57
+ error: e.message,
58
+ error_class: e.class.name
59
+ }
60
+ )
46
61
  Otto.logger.debug e.backtrace.join("\n") if Otto.debug
47
62
  end
48
63
  self
@@ -51,7 +66,7 @@ class Otto
51
66
  def handle_request(env)
52
67
  locale = determine_locale env
53
68
  env['rack.locale'] = locale
54
- env['otto.locale_config'] = @locale_config if @locale_config
69
+ env['otto.locale_config'] = @locale_config.to_h if @locale_config
55
70
  @static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
56
71
  path_info = Rack::Utils.unescape(env['PATH_INFO'])
57
72
  path_info = '/' if path_info.to_s.empty?
@@ -81,14 +96,30 @@ class Otto
81
96
  literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
82
97
 
83
98
  if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
84
- # Otto.logger.debug " request: #{path_info} (static)"
99
+ Otto.structured_log(:debug, "Route matched",
100
+ Otto::LoggingHelpers.request_context(env).merge(
101
+ type: 'static_cached',
102
+ base_path: base_path
103
+ )
104
+ )
85
105
  static_route.call(env)
86
106
  elsif literal_routes.has_key?(path_info_clean)
87
107
  route = literal_routes[path_info_clean]
88
- # Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
108
+ Otto.structured_log(:debug, "Route matched",
109
+ Otto::LoggingHelpers.request_context(env).merge(
110
+ type: 'literal',
111
+ handler: route.route_definition.definition,
112
+ auth_strategy: route.route_definition.auth_requirement || 'none'
113
+ )
114
+ )
89
115
  route.call(env)
90
116
  elsif static_route && http_verb == :GET && safe_file?(path_info)
91
- Otto.logger.debug " new static route: #{base_path} (#{path_info})" if Otto.debug
117
+ Otto.structured_log(:debug, "Route matched",
118
+ Otto::LoggingHelpers.request_context(env).merge(
119
+ type: 'static_new',
120
+ base_path: base_path
121
+ )
122
+ )
92
123
  routes_static[:GET][base_path] = base_path
93
124
  static_route.call(env)
94
125
  else
@@ -123,7 +154,6 @@ class Otto
123
154
  valid_routes.push(*routes[:GET]) if http_verb == :HEAD
124
155
 
125
156
  valid_routes.each do |route|
126
- # Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
127
157
  next unless (match = route.pattern.match(path_info))
128
158
 
129
159
  values = match.captures.to_a
@@ -133,13 +163,36 @@ class Otto
133
163
  values.shift
134
164
  extra_params = build_route_params(route, values)
135
165
  found_route = route
166
+
167
+ # Log successful route match
168
+ Otto.structured_log(:debug, "Route matched",
169
+ Otto::LoggingHelpers.request_context(env).merge(
170
+ pattern: route.pattern.source,
171
+ handler: route.route_definition.definition,
172
+ auth_strategy: route.route_definition.auth_requirement || 'none',
173
+ route_params: extra_params
174
+ )
175
+ )
136
176
  break
137
177
  end
138
178
 
139
179
  found_route ||= literal_routes['/404']
140
180
  if found_route
181
+ # Log 404 route usage if we fell back to it
182
+ if found_route == literal_routes['/404']
183
+ Otto.structured_log(:info, "Route not found",
184
+ Otto::LoggingHelpers.request_context(env).merge(
185
+ fallback_to: '404_route'
186
+ )
187
+ )
188
+ end
141
189
  found_route.call env, extra_params
142
190
  else
191
+ Otto.structured_log(:info, "Route not found",
192
+ Otto::LoggingHelpers.request_context(env).merge(
193
+ fallback_to: 'default_not_found'
194
+ )
195
+ )
143
196
  @not_found || Otto::Static.not_found
144
197
  end
145
198
  end
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/core/uri_generator.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require 'uri'
6
6
 
data/lib/otto/core.rb ADDED
@@ -0,0 +1,10 @@
1
+ # lib/otto/core.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'core/router'
6
+ require_relative 'core/file_safety'
7
+ require_relative 'core/configuration'
8
+ require_relative 'core/error_handler'
9
+ require_relative 'core/uri_generator'
10
+ require_relative 'core/middleware_stack'
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/design_system.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
6
  # Shared design system for Otto framework examples
data/lib/otto/env_keys.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # lib/otto/env_keys.rb
2
2
  #
3
+ # frozen_string_literal: true
4
+ #
3
5
  # Central registry of all env['otto.*'] keys used throughout Otto framework.
4
6
  # This documentation helps prevent key conflicts and aids multi-app integration.
5
7
  #
@@ -36,22 +38,18 @@ class Otto
36
38
 
37
39
  # Authentication strategy result containing session/user state
38
40
  # Type: Otto::Security::Authentication::StrategyResult
39
- # Set by: AuthenticationMiddleware
41
+ # Set by: RouteAuthWrapper (wraps all route handlers)
40
42
  # Used by: RouteHandlers, LogicClasses, Controllers
41
- # Note: Always present (anonymous or authenticated)
43
+ # Guarantee: ALWAYS present - either authenticated or anonymous
44
+ # - Routes WITH auth requirement: Authenticated StrategyResult or 401/302
45
+ # - Routes WITHOUT auth requirement: Anonymous StrategyResult
42
46
  STRATEGY_RESULT = 'otto.strategy_result'
43
47
 
44
- # Authenticated user object (convenience accessor)
45
- # Type: Hash, Custom User Object, or nil
46
- # Set by: AuthenticationMiddleware (from strategy_result.user)
47
- # Used by: Controllers, RouteHandlers
48
- USER = 'otto.user'
48
+ # REMOVED: Use strategy_result.user instead
49
+ # USER = 'otto.user'
49
50
 
50
- # User-specific context (session, roles, permissions, etc.)
51
- # Type: Hash
52
- # Set by: AuthenticationMiddleware (from strategy_result.user_context)
53
- # Used by: Controllers, Analytics
54
- USER_CONTEXT = 'otto.user_context'
51
+ # REMOVED: Use strategy_result.metadata instead
52
+ # USER_CONTEXT = 'otto.user_context'
55
53
 
56
54
  # =========================================================================
57
55
  # SECURITY & CONFIGURATION
@@ -101,6 +99,61 @@ class Otto
101
99
  # Used by: Error responses, logging, support
102
100
  ERROR_ID = 'otto.error_id'
103
101
 
102
+ # =========================================================================
103
+ # PRIVACY (IP MASKING)
104
+ # =========================================================================
105
+
106
+ # Privacy-safe masked IP address
107
+ # Type: String (e.g., '192.168.1.0')
108
+ # Set by: IPPrivacyMiddleware
109
+ # Used by: Rate limiting, analytics, logging
110
+ module Privacy
111
+ MASKED_IP = 'otto.privacy.masked_ip'
112
+
113
+ # Geo-location country code
114
+ # Type: String (ISO 3166-1 alpha-2)
115
+ # Set by: IPPrivacyMiddleware
116
+ # Used by: Analytics, localization
117
+ GEO_COUNTRY = 'otto.privacy.geo_country'
118
+
119
+ # Daily-rotating IP hash for session correlation
120
+ # Type: String (hexadecimal)
121
+ # Set by: IPPrivacyMiddleware
122
+ # Used by: Session correlation without storing IPs
123
+ HASHED_IP = 'otto.privacy.hashed_ip'
124
+
125
+ # Privacy fingerprint object
126
+ # Type: Otto::Privacy::RedactedFingerprint
127
+ # Set by: IPPrivacyMiddleware
128
+ # Used by: Full privacy context access
129
+ FINGERPRINT = 'otto.privacy.fingerprint'
130
+ end
131
+
132
+ # =========================================================================
133
+ # ORIGINAL VALUES (Privacy Disabled)
134
+ # =========================================================================
135
+
136
+ # Original client IP address (only when privacy disabled)
137
+ # Type: String
138
+ # Set by: IPPrivacyMiddleware (when privacy disabled)
139
+ # Used by: Debugging, legitimate use cases requiring real IP
140
+ # NOTE: Not available when privacy is enabled (intentional)
141
+ ORIGINAL_IP = 'otto.original_ip'
142
+
143
+ # Original User-Agent string (only when privacy disabled)
144
+ # Type: String
145
+ # Set by: IPPrivacyMiddleware (when privacy disabled)
146
+ # Used by: Bot detection, browser feature detection
147
+ # NOTE: Not available when privacy is enabled (intentional)
148
+ ORIGINAL_USER_AGENT = 'otto.original_user_agent'
149
+
150
+ # Original Referer URL (only when privacy disabled)
151
+ # Type: String
152
+ # Set by: IPPrivacyMiddleware (when privacy disabled)
153
+ # Used by: Analytics, debugging
154
+ # NOTE: Not available when privacy is enabled (intentional)
155
+ ORIGINAL_REFERER = 'otto.original_referer'
156
+
104
157
  # =========================================================================
105
158
  # MCP (MODEL CONTEXT PROTOCOL)
106
159
  # =========================================================================
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/helpers/base.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
6
  # Base helper methods providing core functionality for Otto applications
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/helpers/request.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require_relative 'base'
6
6
 
@@ -13,6 +13,89 @@ class Otto
13
13
  env['HTTP_USER_AGENT']
14
14
  end
15
15
 
16
+ # NOTE: We do NOT override Rack::Request#ip
17
+ #
18
+ # IPPrivacyMiddleware masks both REMOTE_ADDR and X-Forwarded-For headers,
19
+ # so Rack's native ip resolution logic works correctly with masked values.
20
+ # This allows Rack to handle proxy scenarios (trusted proxies, header parsing)
21
+ # while still returning privacy-safe masked IPs.
22
+ #
23
+ # If you need the masked IP explicitly, use:
24
+ # req.masked_ip # => '192.168.1.0' or nil if privacy disabled
25
+ #
26
+ # If you need the geo country:
27
+ # req.geo_country # => 'US' or nil
28
+ #
29
+ # If you need the full privacy fingerprint:
30
+ # req.redacted_fingerprint # => RedactedFingerprint object or nil
31
+
32
+ # Get the privacy-safe fingerprint for this request
33
+ #
34
+ # Returns nil if IP privacy is disabled. The fingerprint contains
35
+ # anonymized request information suitable for logging and analytics.
36
+ #
37
+ # @return [Otto::Privacy::RedactedFingerprint, nil] Privacy-safe fingerprint
38
+ # @example
39
+ # fingerprint = req.redacted_fingerprint
40
+ # fingerprint.masked_ip # => '192.168.1.0'
41
+ # fingerprint.country # => 'US'
42
+ def redacted_fingerprint
43
+ env['otto.redacted_fingerprint']
44
+ end
45
+
46
+ # Get the geo-location country code for the request
47
+ #
48
+ # Returns ISO 3166-1 alpha-2 country code or 'XX' for unknown.
49
+ # Only available when IP privacy is enabled (default).
50
+ #
51
+ # @return [String, nil] Country code or nil if privacy disabled
52
+ # @example
53
+ # req.geo_country # => 'US'
54
+ def geo_country
55
+ redacted_fingerprint&.country || env['otto.geo_country']
56
+ end
57
+
58
+ # Get anonymized user agent string
59
+ #
60
+ # Returns user agent with version numbers stripped for privacy.
61
+ # When privacy is enabled (default), env['HTTP_USER_AGENT'] is already
62
+ # anonymized by IPPrivacyMiddleware, so this just returns that value.
63
+ # When privacy is disabled, returns the raw user agent.
64
+ #
65
+ # @return [String, nil] Anonymized (or raw if privacy disabled) user agent
66
+ # @example
67
+ # req.anonymized_user_agent
68
+ # # => 'Mozilla/X.X (Windows NT X.X; Win64; x64) AppleWebKit/X.X'
69
+ # @deprecated Use env['HTTP_USER_AGENT'] directly (already anonymized when privacy enabled)
70
+ def anonymized_user_agent
71
+ user_agent
72
+ end
73
+
74
+ # Get masked IP address
75
+ #
76
+ # Returns privacy-safe masked IP. When privacy is enabled (default),
77
+ # this returns the masked version. When disabled, returns original IP.
78
+ #
79
+ # @return [String, nil] Masked or original IP address
80
+ # @example
81
+ # req.masked_ip # => '192.168.1.0'
82
+ def masked_ip
83
+ env['otto.masked_ip'] || env['REMOTE_ADDR']
84
+ end
85
+
86
+ # Get hashed IP for session correlation
87
+ #
88
+ # Returns daily-rotating hash of the IP address, allowing session
89
+ # tracking without storing the original IP. Only available when
90
+ # IP privacy is enabled (default).
91
+ #
92
+ # @return [String, nil] Hexadecimal hash string or nil
93
+ # @example
94
+ # req.hashed_ip # => 'a3f8b2c4d5e6f7...'
95
+ def hashed_ip
96
+ redacted_fingerprint&.hashed_ip || env['otto.hashed_ip']
97
+ end
98
+
16
99
  def client_ipaddress
17
100
  remote_addr = env['REMOTE_ADDR']
18
101
 
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/helpers/response.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require_relative 'base'
6
6
 
@@ -14,10 +14,10 @@ class Otto
14
14
  def send_secure_cookie(name, value, ttl, opts = {})
15
15
  # Default security options
16
16
  defaults = {
17
- secure: true,
18
- httponly: true,
17
+ secure: true,
18
+ httponly: true,
19
19
  same_site: :strict,
20
- path: '/',
20
+ path: '/',
21
21
  }
22
22
 
23
23
  # Merge with provided options
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/helpers/validation.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require 'loofah'
6
6
  require 'facets/file'
@@ -0,0 +1,6 @@
1
+ # lib/otto/helpers.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'helpers/request'
6
+ require_relative 'helpers/response'
@@ -0,0 +1,56 @@
1
+ # lib/otto/locale/config.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative '../core/freezable'
6
+
7
+ class Otto
8
+ module Locale
9
+ # Locale configuration for Otto applications
10
+ #
11
+ # This class manages locale-related settings including available locales
12
+ # and default locale selection.
13
+ #
14
+ # @example Basic usage
15
+ # config = Otto::Locale::Config.new
16
+ # config.available_locales = { 'en' => 'English', 'es' => 'Spanish' }
17
+ # config.default_locale = 'en'
18
+ #
19
+ # @example With initialization
20
+ # config = Otto::Locale::Config.new(
21
+ # available_locales: { 'en' => 'English', 'fr' => 'French' },
22
+ # default_locale: 'en'
23
+ # )
24
+ class Config
25
+ include Otto::Core::Freezable
26
+
27
+ attr_accessor :available_locales, :default_locale
28
+
29
+ # Initialize locale configuration
30
+ #
31
+ # @param available_locales [Hash, nil] Hash of locale codes to names
32
+ # @param default_locale [String, nil] Default locale code
33
+ def initialize(available_locales: nil, default_locale: nil)
34
+ @available_locales = available_locales
35
+ @default_locale = default_locale
36
+ end
37
+
38
+ # Convert to hash for compatibility with existing code
39
+ #
40
+ # @return [Hash] Hash representation of configuration
41
+ def to_h
42
+ {
43
+ available_locales: @available_locales,
44
+ default_locale: @default_locale,
45
+ }.compact
46
+ end
47
+
48
+ # Check if locale configuration is present
49
+ #
50
+ # @return [Boolean] true if either available_locales or default_locale is set
51
+ def configured?
52
+ !@available_locales.nil? || !@default_locale.nil?
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,160 @@
1
+ # lib/otto/locale/middleware.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module Locale
7
+ # Locale detection and resolution middleware
8
+ #
9
+ # Sets env['otto.locale'] based on:
10
+ # 1. URL parameter (?locale=es)
11
+ # 2. Session preference (session['locale'])
12
+ # 3. HTTP Accept-Language header
13
+ # 4. Default locale
14
+ #
15
+ # Configuration:
16
+ # use Otto::Locale::Middleware,
17
+ # available_locales: { 'en' => 'English', 'es' => 'Spanish' },
18
+ # default_locale: 'en',
19
+ # debug: false
20
+ #
21
+ # @example Basic usage
22
+ # use Otto::Locale::Middleware,
23
+ # available_locales: { 'en' => 'English', 'es' => 'Español', 'fr' => 'Français' },
24
+ # default_locale: 'en'
25
+ #
26
+ # @example With session persistence
27
+ # use Rack::Session::Cookie, secret: 'secret'
28
+ # use Otto::Locale::Middleware,
29
+ # available_locales: { 'en' => 'English', 'es' => 'Español' },
30
+ # default_locale: 'en'
31
+ #
32
+ class Middleware
33
+ attr_reader :available_locales, :default_locale
34
+
35
+ # Initialize locale middleware
36
+ #
37
+ # @param app [#call] Rack application
38
+ # @param available_locales [Hash<String, String>] Hash of locale codes to language names
39
+ # @param default_locale [String] Default locale code
40
+ # @param debug [Boolean] Enable debug logging
41
+ def initialize(app, available_locales:, default_locale:, debug: false)
42
+ @app = app
43
+ @available_locales = available_locales
44
+ @default_locale = default_locale
45
+ @debug = debug
46
+
47
+ validate_config!
48
+ end
49
+
50
+ # Process request and set locale
51
+ #
52
+ # @param env [Hash] Rack environment
53
+ # @return [Array] Rack response tuple [status, headers, body]
54
+ def call(env)
55
+ locale = detect_locale(env)
56
+ env['otto.locale'] = locale
57
+
58
+ debug_log(env, locale) if @debug
59
+
60
+ @app.call(env)
61
+ end
62
+
63
+ private
64
+
65
+ # Detect locale using priority chain
66
+ #
67
+ # @param env [Hash] Rack environment
68
+ # @return [String] Resolved locale code
69
+ def detect_locale(env)
70
+ # 1. Check URL parameter
71
+ req = Rack::Request.new(env)
72
+ locale = req.params['locale']
73
+ return locale if valid_locale?(locale)
74
+
75
+ # 2. Check session
76
+ session = env['rack.session']
77
+ locale = session['locale'] if session
78
+ return locale if valid_locale?(locale)
79
+
80
+ # 3. Parse Accept-Language header
81
+ locale = parse_accept_language(env['HTTP_ACCEPT_LANGUAGE'])
82
+ return locale if valid_locale?(locale)
83
+
84
+ # 4. Default
85
+ @default_locale
86
+ end
87
+
88
+ # Parse Accept-Language header with RFC 2616 quality value support
89
+ #
90
+ # Handles formats like:
91
+ # - "en-US,en;q=0.9,fr;q=0.8" → finds first available from [en, en, fr]
92
+ # - "es,en;q=0.9" → returns "en" if "es" unavailable but "en" is
93
+ # - "fr-CA" → "fr"
94
+ #
95
+ # Respects q-values (quality factors) and returns the highest-priority
96
+ # available locale instead of just the first language tag.
97
+ #
98
+ # @param header [String, nil] Accept-Language header value
99
+ # @return [String, nil] Best matching available locale code or nil
100
+ def parse_accept_language(header)
101
+ return nil unless header
102
+
103
+ # Parse all language tags with their q-values
104
+ # Format: "en-US,en;q=0.9,fr;q=0.8" → [[en-US, 1.0], [en, 0.9], [fr, 0.8]]
105
+ languages = header.split(',').map do |tag|
106
+ # Split on semicolon and extract q-value
107
+ parts = tag.strip.split(/\s*;\s*q\s*=\s*/)
108
+ locale_str = parts[0]
109
+ q_value = parts[1] ? parts[1].to_f : 1.0
110
+ [locale_str, q_value]
111
+ end
112
+
113
+ # Sort by q-value descending (highest preference first)
114
+ # and find the first locale that matches available_locales
115
+ languages.sort_by { |_, q| -q }.each do |lang_tag, _|
116
+ # Extract primary language code: "en-US" → "en", "fr" → "fr"
117
+ locale_code = lang_tag.split('-').first.downcase
118
+ return locale_code if valid_locale?(locale_code)
119
+ end
120
+
121
+ nil # No matching locale found
122
+ rescue StandardError => ex
123
+ Otto.logger&.warn "[Otto::Locale] Failed to parse Accept-Language: #{ex.message}"
124
+ nil
125
+ end
126
+
127
+ # Check if locale is valid
128
+ #
129
+ # @param locale [String, nil] Locale code to validate
130
+ # @return [Boolean] true if locale is in available_locales
131
+ def valid_locale?(locale)
132
+ return false unless locale
133
+ @available_locales.key?(locale.to_s)
134
+ end
135
+
136
+ # Validate middleware configuration
137
+ #
138
+ # @raise [ArgumentError] if configuration is invalid
139
+ def validate_config!
140
+ raise ArgumentError, 'available_locales must be a Hash' unless @available_locales.is_a?(Hash)
141
+ raise ArgumentError, 'available_locales cannot be empty' if @available_locales.empty?
142
+ raise ArgumentError, 'default_locale must be in available_locales' unless @available_locales.key?(@default_locale)
143
+ end
144
+
145
+ # Log debug information about locale detection
146
+ #
147
+ # @param env [Hash] Rack environment
148
+ # @param locale [String] Resolved locale
149
+ def debug_log(env, locale)
150
+ Otto.logger&.debug format(
151
+ '[Otto::Locale] Selected locale=%s (param=%s session=%s header=%s)',
152
+ locale,
153
+ Rack::Request.new(env).params['locale'] || 'nil',
154
+ env['rack.session']&.dig('locale') || 'nil',
155
+ env['HTTP_ACCEPT_LANGUAGE']&.split(',')&.first || 'nil'
156
+ )
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,10 @@
1
+ # lib/otto/locale.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module Locale
7
+ autoload :Config, 'otto/locale/config'
8
+ autoload :Middleware, 'otto/locale/middleware'
9
+ end
10
+ end