Otto v2.0.0 Migration Guide

Overview

This guide covers upgrading from Otto v1.x to v2.0.0. If you are already on v2.x, see the later guides in this directory (for example v2.3.0) for changes since.

Otto v2.0.0 is a major release introducing a modular architecture, security and privacy by default, and handler-level authentication. The main themes are:

  • Modular Architecture: Core functionality extracted into focused modules (Router, Configuration, ErrorHandler, etc.) using composition patterns
  • Security by Default: IP privacy middleware, configuration freezing, backtrace sanitization
  • Privacy by Default: Public IP masking runs automatically; no original values stored
  • Handler-Level Authentication: Auth moved from middleware to RouteAuthWrapper, executing after routing for precise control
  • Improved Developer Experience: Unified middleware API, typed request/response helpers, structured logging

Breaking Changes Summary

Change Before After
Middleware registration otto.middleware_stack << otto.use()
Authentication location Middleware (before routing) RouteAuthWrapper (after routing)
Logic class constructor initialize(session, user, params, locale) initialize(context, params, locale)
StrategyResult checks success? / failure? authenticated? / auth_attempt_succeeded?
Request completion callback Otto.on_request_complete (class) otto.on_request_complete (instance)
Timing precision Milliseconds Microseconds

Quick Migration Checklist

  • [ ] Replace otto.middleware_stack << with otto.use()
  • [ ] Remove enable_authentication! calls (RouteAuthWrapper handles auth automatically)
  • [ ] Update Logic class constructors: 4 params to 3 params (context, params, locale)
  • [ ] Replace @session/@user with @context in Logic classes
  • [ ] Replace success?/failure? with authenticated? or type checks
  • [ ] Change Otto.on_request_complete to otto.on_request_complete (instance method)
  • [ ] Update timing expectations from milliseconds to microseconds
  • [ ] Register custom error handlers before first request
  • [ ] Register request/response helpers before first request
  • [ ] Run full test suite and verify all routes work

Detailed Migration Sections

Middleware Configuration

The middleware stack API has been unified for consistency and performance.

Before:

otto.middleware_stack << SomeMiddleware
otto.middleware.add(AnotherMiddleware)

After:

otto.use(SomeMiddleware)
otto.use(AnotherMiddleware)

Key improvements:

  • O(1) middleware lookup using Set-based tracking
  • Memoized middleware list reduces array creation
  • Prevents duplicate middleware registrations
  • Use otto.middleware.includes?() for stack checks

See v2.0.0-pre1 migration guide for detailed examples.

Authentication Setup

Authentication has moved from middleware to handler level via RouteAuthWrapper.

What changed:

  • AuthenticationMiddleware removed (executed before routing)
  • enable_authentication! removed (no longer needed)
  • RouteAuthWrapper now wraps all routes automatically
  • env['otto.strategy_result'] guaranteed present on all routes

Configuration:

# Add strategies (before first request)
otto.add_auth_strategy('session', SessionStrategy.new)
otto.add_auth_strategy('apikey', APIKeyStrategy.new(api_keys: ENV.fetch('API_KEYS').split(',')))

# Configure login redirect path (optional)
otto.auth_config[:login_path] = '/auth/login'  # Default: '/signin'

Route definitions:

# Routes file - strategies execute left-to-right, first success wins
GET /api/data  DataLogic#show  auth=session,apikey

# With role requirements (OR logic)
GET /admin/users  AdminLogic  auth=session role=admin,editor

Logic Class Constructor Pattern

Logic classes now receive an immutable context object instead of separate parameters.

Before:

class MyLogic
  def initialize(session, user, params, locale)
    @session = session
    @user = user
    @params = params
    @locale = locale
  end

  def raise_concerns
    raise 'Access denied' unless @user&.dig('role') == 'admin'
  end
end

After:

class MyLogic
  def initialize(context, params, locale)
    @context = context
    @params = params
    @locale = locale
  end

  def raise_concerns
    raise 'Access denied' unless @context.has_role?('admin')
  end
end

Context API:

@context.authenticated?           # User in session (request state)
@context.anonymous?               # Not authenticated
@context.auth_attempt_succeeded?  # Auth strategy just succeeded (auth outcome)
@context.user_id                  # User ID
@context.user_name                # User name
@context.session_id               # Session ID
@context.has_role?('admin')       # Role check
@context.has_permission?('write') # Permission check
@context.roles                    # Array of roles
@context.permissions              # Array of permissions

See v2.0.0-pre1 migration guide for comprehensive examples.

StrategyResult Semantics

The success? and failure? methods have been removed. Use semantic methods instead.

Before:

if @strategy_result.success?
  # Always true for StrategyResult, meaningless
end

After:

# For "user in session" checks (registration, profile access)
if @strategy_result.authenticated?
  raise "Already signed up"
end

# For "just logged in" checks (redirects, welcome messages)
if @strategy_result.auth_attempt_succeeded?
  redirect_to dashboard_path
end

# For type checking
if strategy_result.is_a?(Otto::Security::Authentication::StrategyResult)
  # handle authenticated request
end

See v2.0.0-pre2 migration guide for detailed semantic explanations.

Error Handling

Register handlers for expected business logic errors to avoid 500 error logging.

otto = Otto.new('routes.txt')

# Register before first request
otto.register_error_handler(YourApp::NotFound, status: 404, log_level: :info)
otto.register_error_handler(YourApp::RateLimited, status: 429, log_level: :warn)

# With custom response handler
otto.register_error_handler(YourApp::ValidationError, status: 422) do |error, env|
  { errors: error.details }.to_json
end

Framework error classes now have proper HTTP status codes:

  • Otto::NotFoundError - 404
  • Otto::BadRequestError - 400
  • Otto::ForbiddenError - 403
  • Otto::UnauthorizedError - 401
  • Otto::PayloadTooLargeError - 413

Request/Response Helpers

Register application-specific helpers that integrate with Otto features.

module YourApp::RequestHelpers
  def current_customer
    user = strategy_result&.user
    user.is_a?(YourApp::Customer) ? user : YourApp::Customer.anonymous
  end
end

module YourApp::ResponseHelpers
  def set_customer_cookie(customer)
    set_cookie('customer_id', value: customer.id, httponly: true)
  end
end

otto = Otto.new('routes.txt')
otto.register_request_helpers(YourApp::RequestHelpers)
otto.register_response_helpers(YourApp::ResponseHelpers)

Helpers are included at class level, not per-request extension.

Reserved method names - Do not override these in helper modules:

  • Request: env, params, cookies, session, path, request_method, ip, etc.
  • Response: status, headers, body, finish, write, redirect, etc.
  • Otto-specific: request, app_path, masked_ip, hashed_ip, secure?, local?, ajax?

Request Completion Callbacks

Callbacks are now instance methods to prevent duplicate invocations in multi-app architectures.

Before:

Otto.on_request_complete { |req, res, duration| ... }

After:

otto.on_request_complete { |req, res, duration| ... }

The callback now receives a Rack::Response object instead of [status, headers, body] tuple.

Timing Precision

All timing now uses microseconds via Otto::Utils.now_in_μs.

Before: 15.2 (milliseconds as float) After: 15200 (microseconds as integer)

Update any code that parses or displays timing values.

New Features to Adopt

IP Privacy Middleware

Automatically masks public IP addresses while preserving private/localhost IPs:

  • Public IPs masked: 192.0.2.100 becomes 192.0.2.0
  • Private IPs preserved: 127.0.0.1, 192.168.x.x, 10.x.x.x
  • Runs FIRST in middleware stack

No configuration needed - enabled by default.

Structured Logging

Otto.structured_log(:debug, "Route matched",
  Otto::LoggingHelpers.request_context(env).merge(
    type: 'literal',
    handler: route.definition
  )
)

# For timed operations
Otto::LoggingHelpers.log_timed_operation(:info, "Operation", env, key: value) do
  perform_operation()
end

Configuration Freezing

Otto automatically freezes all configuration after the first request. Complete all setup before handling requests:

otto = Otto.new('routes.txt')

# All configuration must happen here
otto.add_auth_strategy('session', SessionStrategy.new)
otto.register_error_handler(MyError, status: 400)
otto.register_request_helpers(MyHelpers)

# Configuration freezes on first request
run otto

Base Error Classes

Subclass framework error classes for consistent HTTP semantics:

class RecordNotFound < Otto::NotFoundError; end      # Returns 404
class InvalidInput < Otto::BadRequestError; end      # Returns 400
class AccessDenied < Otto::ForbiddenError; end       # Returns 403

Routes with JSON Response Type

Routes declaring response=json now return JSON errors instead of redirects:

# Routes file
GET /api/data  ApiLogic#show  auth=session response=json

Auth failures return 401 JSON instead of 302 redirect.

Testing Your Migration

# Run full test suite
bundle exec rspec

# Verify specific patterns were updated
grep -r "@strategy_result.success?" app/
grep -r "middleware_stack <<" config/
grep -r "Otto.on_request_complete" app/

Questions?