Module: Otto::Core::Configuration

Includes:
Freezable
Included in:
Otto
Defined in:
lib/otto/core/configuration.rb

Overview

Configuration module providing locale and application configuration methods

Instance Method Summary collapse

Methods included from Freezable

#deep_freeze!

Instance Method Details

#configure(available_locales: nil, default_locale: nil) ⇒ Object

Configure locale settings for the application

Examples:

otto.configure(
  available_locales: { 'en' => 'English', 'es' => 'Spanish', 'fr' => 'French' },
  default_locale: 'en'
)

Parameters:

  • available_locales (Hash) (defaults to: nil)

    Hash of available locales (e.g., { ‘en’ => ‘English’, ‘es’ => ‘Spanish’ })

  • default_locale (String) (defaults to: nil)

    Default locale to use as fallback



204
205
206
207
208
209
210
211
212
213
# File 'lib/otto/core/configuration.rb', line 204

def configure(available_locales: nil, default_locale: nil)
  ensure_not_frozen!

  # Initialize locale_config if not already set
  @locale_config ||= Otto::Locale::Config.new

  # Update configuration
  @locale_config.available_locales = available_locales if available_locales
  @locale_config.default_locale = default_locale if default_locale
end

#configure_auth_strategies(strategies, default_strategy: 'noauth') ⇒ Object

Configure authentication strategies for route-level access control.

Examples:

otto.configure_auth_strategies({
  'noauth' => Otto::Security::Authentication::Strategies::NoAuthStrategy.new,
  'authenticated' => Otto::Security::Authentication::Strategies::SessionStrategy.new(session_key: 'user_id'),
  'role:admin' => Otto::Security::Authentication::Strategies::RoleStrategy.new(['admin']),
  'api_key' => Otto::Security::Authentication::Strategies::APIKeyStrategy.new(api_keys: ['secret123'])
})

Parameters:

  • strategies (Hash)

    Hash mapping strategy names to strategy instances

  • default_strategy (String) (defaults to: 'noauth')

    Default strategy to use when none specified



244
245
246
247
248
249
# File 'lib/otto/core/configuration.rb', line 244

def configure_auth_strategies(strategies, default_strategy: 'noauth')
  ensure_not_frozen!
  # Update existing @auth_config rather than creating a new one
  @auth_config[:auth_strategies] = strategies
  @auth_config[:default_auth_strategy] = default_strategy
end

#configure_authentication(opts) ⇒ Object



78
79
80
81
82
83
84
85
# File 'lib/otto/core/configuration.rb', line 78

def configure_authentication(opts)
  # Update existing @auth_config rather than creating a new one
  # to maintain synchronization with the configurator
  @auth_config[:auth_strategies] = opts[:auth_strategies] if opts[:auth_strategies]
  @auth_config[:default_auth_strategy] = opts[:default_auth_strategy] if opts[:default_auth_strategy]

  # No-op: authentication strategies are configured via @auth_config above
end

#configure_lambda_handlers(opts) ⇒ Object

Validate and freeze the lambda handler registry supplied at construction (issue #41, AC#3). Security: only pre-registered callables are accepted; nothing from route files reaches here, so no eval / dynamic code (AC#8).



106
107
108
# File 'lib/otto/core/configuration.rb', line 106

def configure_lambda_handlers(opts)
  @option[:lambda_handlers] = validate_lambda_handlers!(opts[:lambda_handlers])
end

#configure_locale(opts) ⇒ Object



18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/otto/core/configuration.rb', line 18

def configure_locale(opts)
  # Check if we have any locale configuration
  has_direct_options = opts[:available_locales] || opts[:default_locale]
  has_legacy_config = opts[:locale_config]

  # Only create locale_config if we have configuration
  return unless has_direct_options || has_legacy_config

  # Initialize with direct options
  available_locales = opts[:available_locales]
  default_locale = opts[:default_locale]

  # Legacy support: Configure locale if provided via locale_config hash
  if opts[:locale_config]
    locale_opts = opts[:locale_config]
    available_locales ||= locale_opts[:available_locales] || locale_opts[:available]
    default_locale ||= locale_opts[:default_locale] || locale_opts[:default]
  end

  # Create Otto::Locale::Config instance
  @locale_config = Otto::Locale::Config.new(
    available_locales: available_locales,
    default_locale: default_locale
  )
end

#configure_mcp(opts) ⇒ Object



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/otto/core/configuration.rb', line 87

def configure_mcp(opts)
  @mcp_server = nil

  # Enable MCP if requested in options
  return unless opts[:mcp_enabled] || opts[:mcp_http] || opts[:mcp_stdio]

  @mcp_server = Otto::MCP::Server.new(self)

  mcp_options = {}
  mcp_options[:http_endpoint] = opts[:mcp_endpoint] if opts[:mcp_endpoint]

  return unless opts[:mcp_http] != false # Default to true unless explicitly disabled

  @mcp_server.enable!(mcp_options)
end

#configure_rate_limiting(config) ⇒ Object

Configure rate limiting settings.

Examples:

otto.configure_rate_limiting({
  requests_per_minute: 50,
  custom_rules: {
    'api_calls' => { limit: 30, period: 60, condition: ->(req) { req.path.start_with?('/api') }}
  }
})

Parameters:

  • config (Hash)

    Rate limiting configuration

Options Hash (config):

  • :requests_per_minute (Integer)

    Maximum requests per minute per IP

  • :custom_rules (Hash)

    Hash of custom rate limiting rules

  • :cache_store (Object)

    Custom cache store for rate limiting



228
229
230
231
# File 'lib/otto/core/configuration.rb', line 228

def configure_rate_limiting(config)
  ensure_not_frozen!
  @security_config.rate_limiting_config.merge!(config)
end

#configure_security(opts) ⇒ Object



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/otto/core/configuration.rb', line 44

def configure_security(opts)
  # Enable CSRF protection if requested
  enable_csrf_protection! if opts[:csrf_protection]

  # Enable request validation if requested
  enable_request_validation! if opts[:request_validation]

  # Enable rate limiting if requested
  if opts[:rate_limiting]
    rate_limiting_opts = opts[:rate_limiting].is_a?(Hash) ? opts[:rate_limiting] : {}
    enable_rate_limiting!(rate_limiting_opts)
  end

  # Add trusted proxies if provided
  Array(opts[:trusted_proxies]).each { |proxy| add_trusted_proxy(proxy) } if opts[:trusted_proxies]

  # Set count-based trusted-proxy depth if provided (mutually exclusive
  # with trusted_proxies; conflict validated at configuration freeze).
  # Guard on presence (`unless nil?`), not truthiness, so an explicitly
  # provided invalid value (e.g. `false`) reaches the validating setter
  # and fails loud instead of being silently dropped.
  @security_config.trusted_proxy_depth = opts[:trusted_proxy_depth] unless opts[:trusted_proxy_depth].nil?

  # Select the forwarded header depth mode reads from ('X-Forwarded-For',
  # 'Forwarded', or 'Both'). Only consulted in depth mode. Same presence
  # guard: a provided-but-invalid value is validated, not ignored.
  @security_config.trusted_proxy_header = opts[:trusted_proxy_header] unless opts[:trusted_proxy_header].nil?

  # Set custom security headers
  return unless opts[:security_headers]

  set_security_headers(opts[:security_headers])
end

#ensure_not_frozen!Object

Ensure configuration is not frozen before allowing mutations

Raises:

  • (FrozenError)

    if configuration is frozen



321
322
323
# File 'lib/otto/core/configuration.rb', line 321

def ensure_not_frozen!
  raise FrozenError, 'Cannot modify frozen configuration' if frozen_configuration?
end

#freeze_configuration!self

Freeze the application configuration to prevent runtime modifications. Called automatically at the end of initialization to ensure immutability.

This prevents security-critical configuration from being modified after the application begins handling requests. Uses deep freezing to prevent both direct modification and modification through nested structures.

Returns:

  • (self)

Raises:

  • (RuntimeError)

    if configuration is already frozen



260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'lib/otto/core/configuration.rb', line 260

def freeze_configuration!
  if frozen_configuration?
    Otto.structured_log(:debug, 'Configuration already frozen', { status: 'skipped' }) if Otto.debug
    return self
  end

  start_time = Otto::Utils.now_in_μs

  # Deep freeze configuration objects with memoization support
  @security_config.deep_freeze! if @security_config.respond_to?(:deep_freeze!)
  @locale_config.deep_freeze! if @locale_config.respond_to?(:deep_freeze!)
  @middleware.deep_freeze! if @middleware.respond_to?(:deep_freeze!)

  # Deep freeze configuration hashes (recursively freezes nested structures)
  deep_freeze_value(@auth_config) if @auth_config
  deep_freeze_value(@option) if @option

  # Validate registered handler-wrapper factories against every loaded
  # route before locking the config. Surfaces TypeError / factory bugs
  # at boot instead of on the first request that happens to match.
  validate_handler_wrappers!

  # Deep freeze route structures (prevent modification of nested hashes/arrays)
  deep_freeze_value(@routes) if @routes
  deep_freeze_value(@routes_literal) if @routes_literal
  # @routes_static is intentionally NOT deep-frozen: its :GET entry is a
  # Concurrent::Map that lazy static-file discovery writes into at
  # request time (Core::Router#handle_request, Core::FileSafety#add_static_path),
  # after this method has already run. Deep-freezing it would turn the
  # first request for any as-yet-uncached static file into a
  # FrozenError / 500 in production (issue #185). The outer hash is
  # still shallow-frozen so its verb-key structure (currently just
  # :GET) can't be altered post-freeze, while the Concurrent::Map value
  # stays writable.
  @routes_static.freeze if @routes_static && !@routes_static.frozen?
  deep_freeze_value(@route_definitions) if @route_definitions
  deep_freeze_value(@routes_by_definition) if @routes_by_definition

  @configuration_frozen = true

  duration = Otto::Utils.now_in_μs - start_time
  frozen_objects = %w[security_config locale_config middleware auth_config option routes]
  Otto.structured_log(:info, 'Freezing completed',
    {
            duration: duration,
      frozen_objects: frozen_objects.join(','),
    })

  self
end

#frozen_configuration?Boolean

Check if configuration is frozen

Returns:

  • (Boolean)

    true if configuration is frozen



314
315
316
# File 'lib/otto/core/configuration.rb', line 314

def frozen_configuration?
  @configuration_frozen == true
end

#lambda_handler_accepts_three?(handler) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

True if +handler+ can be invoked with exactly three positional arguments.

Reflects on the callable’s #parameters rather than #arity so that: * non-Proc/Method callables (a plain object with #call) are supported without ever calling #arity, which they need not define (BUG A); and * optional-arg forms that cannot actually take 3 positional args are rejected instead of blanket-accepted by a negative arity (BUG B) – e.g. ->(a=1){} (accepts 0..1) and ->(a,b=1){} (accepts 1..2).

Accepts req/opt/rest combinations that admit 3 positionals; rejects anything requiring more than 3 or unable to reach 3.

Returns:

  • (Boolean)


174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/otto/core/configuration.rb', line 174

def lambda_handler_accepts_three?(handler)
  callable =
    if handler.is_a?(Proc) || handler.is_a?(Method)
      handler
    else
      handler.method(:call)
    end
  params   = callable.parameters
  required = params.count { |(type, _)| type == :req }
  optional = params.count { |(type, _)| type == :opt }
  has_rest = params.any?  { |(type, _)| type == :rest }

  return false if required > 3

  has_rest || (required + optional) >= 3
rescue NameError, NoMethodError
  # A pathological callable whose #method(:call) reflection blows up still
  # yields a clean, handler-named ArgumentError from the caller.
  false
end

#middleware_enabled?(middleware_class) ⇒ Boolean

Returns:

  • (Boolean)


325
326
327
328
# File 'lib/otto/core/configuration.rb', line 325

def middleware_enabled?(middleware_class)
  # Only check the new middleware stack as the single source of truth
  @middleware&.includes?(middleware_class)
end

#validate_handler_wrappers!void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Walk every loaded route and exercise the registered handler-wrapper factories against a sentinel inner handler. Each factory must return a callable; HandlerFactory.apply_handler_wrappers raises TypeError otherwise. The constructed chain is discarded — this is a fail-fast validation pass, not memoization.

Iterates @routes (covers MCP routes added directly) uniquified by identity. No-op if no wrappers are registered or no routes are loaded.



341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
# File 'lib/otto/core/configuration.rb', line 341

def validate_handler_wrappers!
  return unless @routes && @route_handler_factory
  return if @handler_wrappers.nil? || @handler_wrappers.empty?

  sentinel = ->(_env, _extra = {}) { [200, {}, []] }
  seen = {}.compare_by_identity
  @routes.each_value do |routes_for_verb|
    routes_for_verb.each do |route|
      next if seen[route]

      seen[route] = true
      Otto::RouteHandlers::HandlerFactory.apply_handler_wrappers(
        sentinel, route.route_definition, self
      )
    end
  end
end

#validate_lambda_handlers!(handlers) ⇒ Hash

Keys are normalized to Strings so lookups from +&name+ routes (whose target is always a String parsed from the route file) resolve regardless of whether the caller registered handlers under Symbol or String keys. A fresh Hash is built and frozen so the caller’s input object is never mutated in place.

Returns:

  • (Hash)

    frozen registry ({}.freeze when none supplied)

Raises:

  • (ArgumentError)

    naming the offending handler on any invalid entry



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/otto/core/configuration.rb', line 118

def validate_lambda_handlers!(handlers)
  return {}.freeze if handlers.nil?

  unless handlers.is_a?(Hash)
    raise ArgumentError,
          "Otto :lambda_handlers must be a Hash of name => callable, got #{handlers.class}"
  end

  registry = {}

  handlers.each do |name, handler|
    key = name.to_s
    if key.strip.empty?
      raise ArgumentError,
            "Lambda handler name #{name.inspect} is blank " \
            '(expected a non-empty name matching the &handler_name route target)'
    end

    if registry.key?(key)
      raise ArgumentError,
            "Lambda handler name #{key.inspect} is registered more than once " \
            '(String and Symbol keys collide once normalized to a String)'
    end

    unless handler.respond_to?(:call)
      raise ArgumentError,
            "Lambda handler '#{key}' is not callable (expected an object " \
            "responding to #call, got #{handler.class})"
    end

    unless lambda_handler_accepts_three?(handler)
      raise ArgumentError,
            "Lambda handler '#{key}' has invalid arity " \
            '(must accept 3 arguments: req, res, extra_params)'
    end

    registry[key] = handler
  end

  registry.freeze
end