Class: Otto::Request

Inherits:
Rack::Request
  • Object
show all
Defined in:
lib/otto/request.rb

Overview

Otto’s enhanced Rack::Request class with built-in helpers

This class extends Rack::Request with Otto’s framework helpers for HTTP request handling, privacy, security, and locale management. Projects can register additional helpers via Otto#register_request_helpers.

Examples:

Using Otto’s request in route handlers

def show(req, res)
  req.masked_ip      # Privacy-safe masked IP
  req.geo_country    # ISO country code
  req.check_locale!  # Set locale for request
end

See Also:

  • #register_request_helpers

Instance Method Summary collapse

Instance Method Details

#absolute_suri(host = current_server_name) ⇒ Object



162
163
164
165
# File 'lib/otto/request.rb', line 162

def absolute_suri(host = current_server_name)
  prefix = local? ? 'http://' : 'https://'
  [prefix, host, request_path].join
end

#ajax?Boolean

Returns:

  • (Boolean)


212
213
214
# File 'lib/otto/request.rb', line 212

def ajax?
  env['HTTP_X_REQUESTED_WITH'].to_s.downcase == 'xmlhttprequest'
end

#anonymized_user_agentString?

Deprecated.

Use env directly (already anonymized when privacy enabled)

Get anonymized user agent string

Returns user agent with version numbers stripped for privacy. When privacy is enabled (default), env is already anonymized by IPPrivacyMiddleware, so this just returns that value. When privacy is disabled, returns the raw user agent.

Examples:

req.anonymized_user_agent
# => 'Mozilla/X.X (Windows NT X.X; Win64; x64) AppleWebKit/X.X'

Returns:

  • (String, nil)

    Anonymized (or raw if privacy disabled) user agent



93
94
95
# File 'lib/otto/request.rb', line 93

def anonymized_user_agent
  user_agent
end

#app_path(*paths) ⇒ String

Build application path by joining path segments

This method safely joins multiple path segments, handling duplicate slashes and ensuring proper path formatting. Includes the script name (mount point) as the first segment.

Examples:

app_path('api', 'v1', 'users')
# => "/myapp/api/v1/users"
app_path(['admin', 'settings'])
# => "/myapp/admin/settings"

Parameters:

  • paths (Array<String>)

    Path segments to join

Returns:

  • (String)

    Properly formatted path



390
391
392
393
394
# File 'lib/otto/request.rb', line 390

def app_path(*paths)
  paths = paths.flatten.compact
  paths.unshift(env['SCRIPT_NAME']) if env['SCRIPT_NAME']
  paths.join('/').gsub('//', '/')
end

#blocked_user_agent?(blocked_agents: []) ⇒ Boolean

Check if user agent matches blocked patterns

This method checks if the current request’s user agent string matches any of the provided blocked agent patterns.

Examples:

blocked_user_agent?([:bot, :crawler, 'BadAgent'])
# => false if user agent contains 'bot', 'crawler', or 'BadAgent'

Parameters:

  • blocked_agents (Array<String, Symbol, Regexp>) (defaults to: [])

    Patterns to check against

Returns:

  • (Boolean)

    true if user agent is allowed, false if blocked



358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
# File 'lib/otto/request.rb', line 358

def blocked_user_agent?(blocked_agents: [])
  return true if blocked_agents.empty?

  user_agent_string = user_agent.to_s.downcase
  return true if user_agent_string.empty?

  blocked_agents.flatten.any? do |agent|
    case agent
    when Regexp
      user_agent_string.match?(agent)
    else
      user_agent_string.include?(agent.to_s.downcase)
    end
  end
end

#check_locale!(locale = nil, opts = {}) ⇒ String

Set the locale for the request based on multiple sources

This method determines the locale to be used for the request by checking the following sources in order of precedence:

  1. The locale parameter passed to the method

  2. The locale query parameter in the request

  3. The user’s saved locale preference (if provided)

  4. The rack.locale environment variable

If a valid locale is found, it’s stored in the request environment. If no valid locale is found, the default locale is used.

Examples:

Basic usage

check_locale!(
  available_locales: { 'en' => 'English', 'es' => 'Spanish' },
  default_locale: 'en'
)
# => 'en'

With user preference

check_locale!(nil, {
  available_locales: { 'en' => 'English', 'es' => 'Spanish' },
  default_locale: 'en',
  preferred_locale: 'es'
})
# => 'es'

Using Otto-level configuration

# Otto configured with: Otto.new(routes, { locale_config: { available: {...}, default: 'en' } })
check_locale!('es')  # Uses Otto's config automatically
# => 'es'

Parameters:

  • locale (String, nil) (defaults to: nil)

    The locale to use, if specified

  • opts (Hash) (defaults to: {})

    Configuration options

Options Hash (opts):

  • :available_locales (Hash)

    Hash of available locales to validate against (required unless configured at Otto level)

  • :default_locale (String)

    Default locale to use as fallback (required unless configured at Otto level)

  • :preferred_locale (String, nil)

    User’s saved locale preference

  • :locale_env_key (String)

    Environment key to store the locale (default: ‘locale’)

  • :debug (Boolean)

    Enable debug logging for locale selection

Returns:

  • (String)

    The selected locale



437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
# File 'lib/otto/request.rb', line 437

def check_locale!(locale = nil, opts = {})
  # Get configuration from options, Otto config, or environment (in that order)
  otto_config = env['otto.locale_config']

  available_locales = opts[:available_locales] ||
                      otto_config&.dig(:available_locales) ||
                      env['otto.available_locales']
  default_locale    = opts[:default_locale] ||
                      otto_config&.dig(:default_locale) ||
                      env['otto.default_locale']
  preferred_locale  = opts[:preferred_locale]
  locale_env_key    = opts[:locale_env_key] || 'locale'
  debug_enabled     = opts[:debug] || false

  # Guard clause - required configuration must be present
  unless available_locales.is_a?(Hash) && !available_locales.empty? && default_locale && available_locales.key?(default_locale)
    raise ArgumentError,
          'available_locales must be a non-empty Hash and include default_locale (provide via opts or Otto configuration)'
  end

  # Check sources in order of precedence
  locale ||= env['rack.request.query_hash'] && env['rack.request.query_hash']['locale']
  locale ||= preferred_locale if preferred_locale
  locale ||= (env['rack.locale'] || []).first

  # Validate locale against available translations
  have_translations = locale && available_locales.key?(locale.to_s)

  # Debug logging if enabled
  if debug_enabled && defined?(Otto.logger)
    message = format(
      '[check_locale!] sources[param=%s query=%s user=%s rack=%s] valid=%s',
      locale,
      env.dig('rack.request.query_hash', 'locale'),
      preferred_locale,
      (env['rack.locale'] || []).first,
      have_translations
    )
    Otto.logger.debug message
  end

  # Set the locale in request environment
  selected_locale = have_translations ? locale : default_locale
  env[locale_env_key] = selected_locale

  selected_locale
end

#client_ipaddressObject



122
123
124
125
126
127
128
129
130
131
132
# File 'lib/otto/request.rb', line 122

def client_ipaddress
  # Prefer the canonical client IP resolved once by IPPrivacyMiddleware
  # ("resolve once, read everywhere"). Falls back to the shared resolver
  # (Otto::Utils.resolve_client_ip) for standalone use without the
  # middleware, so the with- and without-middleware paths agree on which
  # forwarded headers to trust and how to walk a proxy chain.
  canonical = env['otto.client_ip']
  return canonical if canonical && !canonical.empty?

  Otto::Utils.resolve_client_ip(env, otto_security_config)
end

#collect_proxy_headers(header_prefix: nil, additional_keys: []) ⇒ String

Collect and format HTTP header details from the request environment

This method extracts and formats specific HTTP headers, including Cloudflare and proxy-related headers, for logging and debugging purposes.

Examples:

Basic usage

collect_proxy_headers
# => "X-Forwarded-For: 203.0.113.195 Remote-Addr: 192.0.2.1"

With custom prefix

collect_proxy_headers(header_prefix: 'X_CUSTOM_')
# => "X-Forwarded-For: 203.0.113.195 X-Custom-Token: abc123"

Parameters:

  • header_prefix (String, nil) (defaults to: nil)

    Custom header prefix to include (e.g. ‘X_SECRET_’)

  • additional_keys (Array<String>) (defaults to: [])

    Additional header keys to collect

Returns:

  • (String)

    Formatted header details as “key: value” pairs



288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/otto/request.rb', line 288

def collect_proxy_headers(header_prefix: nil, additional_keys: [])
  keys = %w[
    HTTP_FLY_REQUEST_ID
    HTTP_VIA
    HTTP_X_FORWARDED_PROTO
    HTTP_X_FORWARDED_FOR
    HTTP_X_FORWARDED_HOST
    HTTP_X_FORWARDED_PORT
    HTTP_X_SCHEME
    HTTP_X_REAL_IP
    HTTP_CF_IPCOUNTRY
    HTTP_CF_RAY
    REMOTE_ADDR
  ]

  # Add any header that begins with the specified prefix
  if header_prefix
    prefix_keys = env.keys.select { _1.upcase.start_with?("HTTP_#{header_prefix.upcase}") }
    keys.concat(prefix_keys)
  end

  # Add any additional keys requested
  keys.concat(additional_keys) if additional_keys.any?

  keys.sort.filter_map do |key|
    value = env[key]
    next unless value

    # Normalize the header name to look like browser dev console
    # e.g. Content-Type instead of HTTP_CONTENT_TYPE
    pretty_name = key.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-')
    "#{pretty_name}: #{value}"
  end.join(' ')
end


216
217
218
# File 'lib/otto/request.rb', line 216

def cookie(name)
  cookies[name.to_s]
end

#cookie?(name) ⇒ Boolean

Returns:

  • (Boolean)


220
221
222
# File 'lib/otto/request.rb', line 220

def cookie?(name)
  !cookie(name).to_s.empty?
end

#current_absolute_uriObject



224
225
226
227
# File 'lib/otto/request.rb', line 224

def current_absolute_uri
  prefix = secure? && !local? ? 'https://' : 'http://'
  [prefix, http_host, request_path].join
end

#current_serverObject



138
139
140
# File 'lib/otto/request.rb', line 138

def current_server
  [current_server_name, env['SERVER_PORT']].join(':')
end

#current_server_nameObject



142
143
144
# File 'lib/otto/request.rb', line 142

def current_server_name
  env['SERVER_NAME']
end

#format_request_details(header_prefix: nil) ⇒ String

Format request details as a single string for logging

This method combines IP address, HTTP method, path, query parameters, and proxy header details into a single formatted string suitable for logging.

Examples:

format_request_details
# => "192.0.2.1; GET /path?query=string; Proxy[X-Forwarded-For: 203.0.113.195 Remote-Addr: 192.0.2.1]"

Parameters:

  • header_prefix (String, nil) (defaults to: nil)

    Custom header prefix for proxy headers

Returns:

  • (String)

    Formatted request details



335
336
337
338
339
340
341
342
343
344
345
# File 'lib/otto/request.rb', line 335

def format_request_details(header_prefix: nil)
  header_details = collect_proxy_headers(header_prefix: header_prefix)

  details = [
    client_ipaddress,
    "#{request_method} #{env['PATH_INFO']}?#{env['QUERY_STRING']}",
    "Proxy[#{header_details}]",
  ]

  details.join('; ')
end

#forwarded_by_trusted_proxy?Boolean

Whether the request arrived through a trusted proxy.

Prefers the canonical decision recorded once by IPPrivacyMiddleware in env — evaluated against the original peer before REMOTE_ADDR is masked, so it stays correct even after masking. Falls back to evaluating the current REMOTE_ADDR when the middleware has not run (standalone request use).

This is the trusted-proxy identity check only and is independent of count-based depth mode: depth resolves the client IP but never grants proxy trust for X-Forwarded-Proto.

Returns:

  • (Boolean)


205
206
207
208
209
# File 'lib/otto/request.rb', line 205

def forwarded_by_trusted_proxy?
  return env['otto.via_trusted_proxy'] if env.key?('otto.via_trusted_proxy')

  otto_security_config ? trusted_proxy?(env['REMOTE_ADDR']) : false
end

#geo_countryString?

Get the geo-location country code for the request

Returns ISO 3166-1 alpha-2 country code or ‘XX’ for unknown. Only available when IP privacy is enabled (default).

Examples:

req.geo_country  # => 'US'

Returns:

  • (String, nil)

    Country code or nil if privacy disabled



77
78
79
# File 'lib/otto/request.rb', line 77

def geo_country
  redacted_fingerprint&.country || env['otto.privacy.geo_country']
end

#hashed_ipString?

Get hashed IP for session correlation

Returns daily-rotating hash of the IP address, allowing session tracking without storing the original IP. Only available when IP privacy is enabled (default).

Examples:

req.hashed_ip  # => 'a3f8b2c4d5e6f7...'

Returns:

  • (String, nil)

    Hexadecimal hash string or nil



118
119
120
# File 'lib/otto/request.rb', line 118

def hashed_ip
  redacted_fingerprint&.hashed_ip || env['otto.privacy.hashed_ip']
end

#http_hostObject



146
147
148
# File 'lib/otto/request.rb', line 146

def http_host
  env['HTTP_HOST']
end

#ipString?

Canonical client IP for the request.

Prefers env — the value resolved once, early, by IPPrivacyMiddleware (“resolve once, read everywhere”): the masked IP when privacy is enabled, or the resolved real IP when privacy is disabled or the address is exempt. This means downstream code no longer depends on REMOTE_ADDR / X-Forwarded-For rewriting being load-bearing.

Falls back to Rack’s native resolution when the middleware has not run (e.g. standalone request use without the Otto middleware stack).

Returns:

  • (String, nil)

    Canonical (privacy-applied) client IP



39
40
41
42
43
44
# File 'lib/otto/request.rb', line 39

def ip
  canonical = env['otto.client_ip']
  return canonical if canonical && !canonical.empty?

  super
end

#local?Boolean

Returns:

  • (Boolean)


167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/otto/request.rb', line 167

def local?
  return false unless Otto.env?(:dev, :development)

  ip = client_ipaddress
  return false unless ip

  # Check both IP and server name for comprehensive localhost detection
  server_name        = env['SERVER_NAME']
  local_server_names = ['localhost', '127.0.0.1', '0.0.0.0']

  local_or_private_ip?(ip) && local_server_names.include?(server_name)
end

#local_or_private_ip?(ip) ⇒ Boolean

Returns:

  • (Boolean)


260
261
262
263
264
265
266
267
268
269
# File 'lib/otto/request.rb', line 260

def local_or_private_ip?(ip)
  return false unless ip

  # Fast path for the common localhost cases (avoids IPAddr allocation);
  # private_ip? would also catch these via IPAddr#loopback?.
  return true if ['127.0.0.1', '::1'].include?(ip)

  # Check for private IP ranges
  private_ip?(ip)
end

#masked_ipString?

Get masked IP address

Returns privacy-safe masked IP. When privacy is enabled (default), this returns the masked version. When disabled, returns original IP.

Examples:

req.masked_ip  # => '192.168.1.0'

Returns:

  • (String, nil)

    Masked or original IP address



105
106
107
# File 'lib/otto/request.rb', line 105

def masked_ip
  env['otto.privacy.masked_ip'] || env['REMOTE_ADDR']
end

#otto_security_configObject



229
230
231
232
233
234
235
236
# File 'lib/otto/request.rb', line 229

def otto_security_config
  # Try to get security config from various sources
  if respond_to?(:otto) && otto.respond_to?(:security_config)
    otto.security_config
  elsif defined?(Otto) && Otto.respond_to?(:security_config)
    Otto.security_config
  end
end

#private_ip?(ip) ⇒ Boolean

Whether the given address is non-public (private, loopback, link-local, multicast or unspecified). IPv4 and IPv6 aware via Otto::Utils.private_ip? — the previous implementation was an IPv4-only regex that silently treated every IPv6 address (including ::1 and ULA fc00::/7) as public.

Parameters:

  • ip (String, IPAddr, nil)

    address to classify

Returns:

  • (Boolean)


256
257
258
# File 'lib/otto/request.rb', line 256

def private_ip?(ip)
  Otto::Utils.private_ip?(ip)
end

#redacted_fingerprintOtto::Privacy::RedactedFingerprint?

Get the privacy-safe fingerprint for this request

Returns nil if IP privacy is disabled. The fingerprint contains anonymized request information suitable for logging and analytics.

Examples:

fingerprint = req.redacted_fingerprint
fingerprint.masked_ip    # => '192.168.1.0'
fingerprint.country      # => 'US'

Returns:



65
66
67
# File 'lib/otto/request.rb', line 65

def redacted_fingerprint
  env['otto.privacy.fingerprint']
end

#request_methodObject



134
135
136
# File 'lib/otto/request.rb', line 134

def request_method
  env['REQUEST_METHOD']
end

#request_pathObject



150
151
152
# File 'lib/otto/request.rb', line 150

def request_path
  env['REQUEST_PATH']
end

#request_uriObject



154
155
156
# File 'lib/otto/request.rb', line 154

def request_uri
  env['REQUEST_URI']
end

#root_pathObject



158
159
160
# File 'lib/otto/request.rb', line 158

def root_path
  env['SCRIPT_NAME']
end

#secure?Boolean

Returns:

  • (Boolean)


180
181
182
183
184
185
186
187
188
189
190
# File 'lib/otto/request.rb', line 180

def secure?
  # Check direct HTTPS connection
  return true if env['HTTPS'] == 'on' || env['SERVER_PORT'] == '443'

  # Only trust forwarded proto headers when the request actually arrived via
  # a trusted proxy.
  return false unless forwarded_by_trusted_proxy?

  # X-Scheme is set by nginx; X-Forwarded-Proto by elastic load balancer
  env['HTTP_X_FORWARDED_PROTO'] == 'https' || env['HTTP_X_SCHEME'] == 'https'
end

#trusted_proxy?(ip) ⇒ Boolean

Returns:

  • (Boolean)


238
239
240
241
242
243
# File 'lib/otto/request.rb', line 238

def trusted_proxy?(ip)
  config = otto_security_config
  return false unless config

  config.trusted_proxy?(ip)
end

#user_agentObject



23
24
25
# File 'lib/otto/request.rb', line 23

def user_agent
  env['HTTP_USER_AGENT']
end

#validate_ip_address(ip) ⇒ Object



245
246
247
# File 'lib/otto/request.rb', line 245

def validate_ip_address(ip)
  Otto::Utils.normalize_ip(ip)
end