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.
Static files
Otto serves static files in two ways. Both apply the same safety policy: the
requested path is joined to a canonical root, resolved with File.realpath
(which follows every .., ., and symlink component), and served only when
the result is still inside that root and is a regular, readable file owned by
the process user or group. Anything else, including a symlink that points
outside the root, is treated as not found.
Implicit public directory
Passing public: serves every file under that directory at its relative path.
Nothing needs registering; a file added after boot is served on the next
request, and a symlinked public directory that is repointed by a deploy is
re-resolved on every request.
otto = Otto.new('routes', public: File.('public', __dir__))
# public/css/site.css is served at GET /css/site.css
Explicit static mounts
mount_static binds one URL prefix to one directory. Use it when the files do
not live under a single public directory, when a URL prefix should map to a
different directory name, or when a required asset directory must be verified
at boot.
otto = Otto.new('routes')
otto.mount_static('/assets', root: 'public/assets')
otto.mount_static('/vendor', root: File.join(Gem.loaded_specs['some-ui-kit'].full_gem_path, 'dist'))
otto.mount_static('/', root: 'public/root-files') # favicon.ico, robots.txt
- The prefix must start with
/. A trailing slash is ignored, and/mounts the root at the top level. Empty,., and..segments are rejected. - The root is expanded and canonicalized once, at registration. A root that
is missing, unreadable, not a directory, not owned by the process user or
group, or a symlink that cannot be resolved raises
ArgumentError, so a misconfigured application does not boot. Because the root is fixed at registration, a deploy that repoints a symlinked root takes effect at the next restart. - A mount authorizes only files inside its own root. It never exposes the
root's parent or siblings, and it does not widen the implicit public
directory. Registering the same prefix twice on one instance raises
ArgumentError; different Otto instances are fully independent. - Requests are matched on the decoded, trailing-slash-stripped path, the same
normalization every other dispatch stage uses. Only
GETis served, the prefix itself is not (mounts serve files, not directory listings), and a request for a file the root does not contain falls through to the next dispatch stage. mount_staticmust be called before the first request. After configuration freezing it raisesFrozenError, andotto.static_mountsis a frozen, read-only table.
Dispatch precedence
Precedence is fixed and does not depend on request history:
- literal routes, such as
GET /assets/app.css Assets#show; - explicit static mounts, consulted longest prefix first; when the longest matching mount does not contain the file, shorter matching mounts are tried in turn;
- the implicit
public:directory; - dynamic routes, such as
GET /assets/:name Assets#show.
So a literal route at a mounted path always wins, a mounted file always beats a file at the same URL in the public directory, and a dynamic route only sees requests that no static source could serve.
Migrating from add_static_path
add_static_path was removed in v2.10.0. It only populated a request-time
cache; it never registered or restricted anything. Callers that used it to
"register" files under the public directory can delete the call, because the
public directory is served without registration. Callers that used it to reach
files outside the public directory should replace it with mount_static and
an explicit root. There is no compatibility shim: calling the removed method
raises NoMethodError at boot.
Fallback 404 and 500 responses
A GET /404 or GET /500 route in the routes file handles misses and
unhandled errors like any other route. Without one, Otto uses not_found= and
server_error=, which accept either a Rack triple or a callable:
otto.not_found = [404, { 'content-type' => 'application/json' }, ['{"error":"Not Found"}']]
otto.server_error = lambda do |env, error|
[500, { 'content-type' => 'text/plain' }, ["Error #{env['otto.error_id']}"]]
end
A callable is invoked on every request with env (not_found) or env and
the exception (server_error), trimmed to the positional parameters it
declares, so ->(env) { ... } and ->(env = nil) { ... } both work for
server_error. It must return a Rack triple: an Integer status, Hash-like
headers, and a body that responds to each (a bare String is rejected, at
assignment time for a static triple). A static triple is copied per request
before it is returned, so middleware that writes response headers in place
(rack-session, Otto's CSRF middleware, anything calling
Rack::Utils.set_cookie_header!) never mutates the configured object or
leaks one client's Set-Cookie into another's response. Do not rely on
mutating the configured triple after boot; assign a new value or use the
callable form instead.
For JSON clients, an unhandled error returns Otto's built-in JSON error body
regardless of server_error; a /500 route applies to every client.
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.