Routing applications with Otto

Otto keeps the application boundary small: a plain-text route file maps an HTTP verb and path to a Ruby handler. Use this guide to choose a handler style and response contract. The exact route grammar is in the route syntax reference.

Choose a handler style

Use this when Route target Invocation
You need a class method with direct Rack access App.index App.index(req, res)
You need an object with direct Rack access App#show App.new(req, res).show
You want a constrained, testable application operation App::Operation App::Operation.new(strategy_result, params, locale)
You need a small pre-registered endpoint function &name call(req, res, captured_path_params)

Controller-style handlers

Routes can call a class method or instantiate a class for an instance method:

GET /                         App.index
GET /products/:id             App#show
class App
  def initialize(req, res)
    @req = req
    @res = res
  end

  def show
    @res.body = "Product: #{@req.params[:id]}"
  end

  def self.index(req, res)
    res['content-type'] = 'text/plain'
    res.body = 'Hello Otto'
  end
end

Use this style when the handler needs cookies, request headers, the Rack request object, or direct response helpers.

Logic classes

Use a bare class target for an operation with an explicit input context:

GET  /products/:id  Products::Show auth=session response=json
POST /products      Products::Create auth=session response=json
class Products::Show
  def initialize(strategy_result, params, locale)
    @context = strategy_result
    @params = params
    @locale = locale
  end

  def raise_concerns
    @product = Product.find(@params[:id])
    unless @product.public? || @product.owner_id == @context.user_id
      raise Otto::Security::AuthorizationError, 'Product access denied'
    end
  end

  def process
    { id: @product.id, name: @product.name, locale: @locale }
  end
end

Otto runs raise_concerns before process when those methods exist. Put resource loading and resource-level authorization in raise_concerns; route authentication and broad role checks belong in the route definition.

Logic classes do not receive the Rack environment. This keeps their inputs explicit and prevents application operations from depending on ambient request state. Choose a controller-style handler when direct request access is part of the operation.

Registered lambda handlers

Lambda routes are useful for small endpoints that do not need a Ruby constant or handler class. Register the callable at boot:

otto = Otto.new('routes', lambda_handlers: {
  health_check: lambda do |_req, res, _extra_params|
    res['content-type'] = 'text/plain'
    res.body = 'ok'
  end,
})
GET /health &health_check

The registry is normalized and frozen during configuration. A lambda must accept three positional arguments: request, response, and captured path parameters. Query and form parameters remain available through req.params. The route name is an exact registry key; it is not evaluated as Ruby code.

Response selection

Use response= when the handler returns a value that should pass through Otto's response handling:

GET  /api/products  Products::Index response=json
GET  /dashboard     Dashboard#show response=view
POST /login         Sessions#create response=redirect
GET  /data          Data#show response=auto

response=default is the default. Keep response selection in the route file so the HTTP contract is visible beside the endpoint.

Response type Handler contract
default Mutate res directly. The handler's return value is ignored.
json Return a Hash for direct JSON serialization. nil becomes { "success": true }; another value is wrapped as data. A Logic class may instead provide response_data.
view Return a value rendered with to_s, or provide view.render on a Logic object.
redirect Return a path String, or provide redirect_path on a Logic object. The fallback path is /.
auto A Hash becomes JSON, a path-like String becomes a redirect, and a Logic object with view uses the view handler; other results use default behavior.

An unknown response name currently falls back to default. Treat response names as a fixed set; a typo otherwise changes the route to direct-response behavior.

Route parameters

Named path segments are available in request parameters:

GET /products/:id  Products::Show

A handler can read req.params[:id] or a Logic class can read params[:id]. Request query and body parameters are merged according to the handler's request contract. JSON bodies are parsed for Logic-class parameters when the content type is JSON and the body is a JSON object. A valid non-object JSON body is ignored. Malformed JSON is logged and the Logic class still runs with its other parameters; perform application validation when malformed JSON must return a client error.

Security options in routes

Authentication, roles, and CSRF exemptions are explicit route options:

GET  /profile  Profile#show auth=session
GET  /admin    Admin::Dashboard auth=session role=admin
POST /hook     Hooks#receive csrf=exempt

Malformed auth, role, and csrf tokens fail route parsing rather than silently weakening the route. Do not use csrf=exempt as a general API switch; choose an independent request-authentication and replay-protection model for webhooks or other non-browser endpoints.

Configuration timing

Construct and configure the Otto instance before the first request:

otto = Otto.new('routes')
otto.add_auth_strategy(
  'session',
  Otto::Security::Authentication::Strategies::SessionStrategy.new
)
otto.register_request_helpers(MyApp::RequestHelpers)
# Add middleware and other boot-time options here.

In normal operation, the first request freezes configuration. Runtime route or security changes are not part of the application contract.