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



228
229
230
231
232
233
234
235
236
237
# File 'lib/otto/core/configuration.rb', line 228

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



268
269
270
271
272
273
# File 'lib/otto/core/configuration.rb', line 268

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



94
95
96
97
98
99
100
101
# File 'lib/otto/core/configuration.rb', line 94

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).



130
131
132
# File 'lib/otto/core/configuration.rb', line 130

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



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/otto/core/configuration.rb', line 103

def configure_mcp(opts)
  @mcp_server = nil

  # Enable MCP if requested in options. The gating keys are read through
  # the MCP option normalizer so that "mcp_enabled" => true enables MCP
  # exactly like mcp_enabled: true, matching the String-or-Symbol
  # contract every other MCP option already honours (#258). The
  # normalizer also raises unless each value is exactly true or false:
  # the `== false` check below would otherwise mount the endpoint for a
  # String "false" from ENV.fetch or YAML, or for nil from an unset ENV.
  gating = Otto::MCP::Options.gating_options(opts)
  return unless gating[:mcp_enabled] || gating[:mcp_http] || gating[:mcp_stdio]

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

  return if gating[:mcp_http] == false # Default to true unless explicitly disabled

  # Forward the whole options hash under the :constructor scope, which
  # picks out the MCP vocabulary (auth_tokens, rate limits, ...) and
  # ignores the rest of Otto's options. Previously only the endpoint
  # survived, silently starting an unauthenticated MCP endpoint (#258).
  @mcp_server.enable!(Otto::MCP::Server.normalize_options(opts, :constructor))
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



252
253
254
255
# File 'lib/otto/core/configuration.rb', line 252

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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# 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
  # `trusted_proxies: :none` is an explicit operator assertion that no
  # proxy is trusted (as opposed to omitting the option, which asserts
  # nothing); see Otto::Security::Config#trust_no_proxies!.
  if Otto::Security::Config.trust_no_proxies_option?(opts[:trusted_proxies])
    @security_config.trust_no_proxies!
  elsif opts[:trusted_proxies]
    # Pass the list whole so add_trusted_proxy validates every entry
    # before registering any (a mixed list containing 'none' is rejected
    # without installing the rest).
    add_trusted_proxy(Array(opts[:trusted_proxies])) unless Array(opts[:trusted_proxies]).empty?
  end

  # 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 family Otto and Rack read from. An explicit
  # option is an operator choice that every Otto app in the process must
  # share; with no option, align Rack with Otto's default family without
  # staking that claim, so a no-options sub-mount never blocks a later
  # explicit choice. A provided-but-invalid value is still validated.
  if opts[:trusted_proxy_header].nil?
    @security_config.apply_default_rack_forwarding_family!
  else
    @security_config.trusted_proxy_header = opts[:trusted_proxy_header]
  end

  # 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



338
339
340
# File 'lib/otto/core/configuration.rb', line 338

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



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
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/otto/core/configuration.rb', line 284

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
  deep_freeze_value(@route_definitions) if @route_definitions
  deep_freeze_value(@routes_by_definition) if @routes_by_definition
  # Explicit static mounts are already immutable snapshots; freezing the
  # array here records that fact and makes any in-place mutation raise.
  deep_freeze_value(@static_mounts) if @static_mounts

  @configuration_frozen = true

  duration = Otto::Utils.now_in_μs - start_time
  frozen_objects = %w[security_config locale_config middleware auth_config option routes static_mounts]
  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



331
332
333
# File 'lib/otto/core/configuration.rb', line 331

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)


198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/otto/core/configuration.rb', line 198

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)


342
343
344
345
# File 'lib/otto/core/configuration.rb', line 342

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.



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

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



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/otto/core/configuration.rb', line 142

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