Routing
Routes map HTTP requests to controller actions. Define your application's URL structure in config/routes.sl.
HTTP Methods
Use these helpers to define routes for different HTTP methods:
| Method | Description | Example |
|---|---|---|
| get | Retrieve a resource | get("/users", "users#index") |
| post | Create a new resource | post("/users", "users#create") |
| put | Update a resource completely | put("/users/:id", "users#update") |
| patch | Update a resource partially | patch("/users/:id", "users#patch") |
| delete | Delete a resource | delete("/users/:id", "users#delete") |
Basic Routes
# Root page
get("/", "home#index")
# Static pages
get("/about", "home#about")get("/contact", "home#contact")
# Form submissions
post("/contact", "home#submit_contact");
Route Parameters
Use :param_name to capture dynamic segments:
# Capture user ID
get("/users/:id", "users#show")
# Multiple parameters
get("/posts/:post_id/comments/:comment_id", "comments#show")
# Optional parameters
get("/users/:id?", "users#show");
Access parameters in your controller:
def show(req: Any)
user_id = req["params"]["id"]
post_id = req["params"]["post_id"]
{
"status": 200,
"body": "User ID: " + user_id
}
end
Splat Routes
Use *param_name to capture the remaining path segments greedously:
# Capture remaining path
get("/files/*filepath", "files#serve")get("/downloads/*filename", "downloads#handle")
# Combine with parameters
get("/users/:id/*action", "users#action")get("/api/*version/users/*id", "api#user")
# Root splat
get("/*path", "catchall#handle");
Captured splat values include a leading slash:
def serve(req: Any)
filepath = req["params"]["filepath"]
# /files/docs/report.pdf -> filepath = "/docs/report.pdf"
# /files/images/photo.jpg -> filepath = "/images/photo.jpg"
{
"status": 200,
"body": "Serving: " + filepath
}
end
| Pattern | Request Path | Captured |
|---|---|---|
| /files/*path | /files/a/b/c | {path: "/a/b/c"} |
| /api/*version/users | /api/v1/users | {version: "/v1"} |
| /users/:id/*action | /users/123/edit | {id: "123", action: "/edit"} |
| /*splat | /any/path/here | {splat: "/any/path/here"} |
Wildcard Action Routes
Use controller#* to dynamically resolve actions from the URL path:
# Dynamic docs routes - /docs/routing → docs#routing
get("/docs/*", "docs#*")
# API routes with version - /api/v1/users → api#users
get("/api/*version/*action", "api#*")
# Files serving - /files/docs/readme → files#docs/readme
get("/files/*filepath", "files#serve");
The action name is resolved from captured splat parameters in this priority order:
splatparameterpath,filepath,filename,action, orresource- First non-nested parameter
class DocsController < Controller
static
this.layout = "docs"
end
def routing(req: Any)
path = req["params"]["path"]; # "/routing"
render("docs/routing", {"active_page": "routing"})
end
def installation(req: Any)
render("docs/installation", {"active_page": "installation"})
end
end
| Route Pattern | Request Path | Action Resolved |
|---|---|---|
| get("/docs/*", "docs#*") | /docs/routing | docs#routing |
| get("/docs/*", "docs#*") | /docs/installation | docs#installation |
| get("/api/*version/*action", "api#*") | /api/v1/users | api#users |
| get("/files/*filepath", "files#serve") | /files/docs/readme | files#docs/readme |
Query Parameters
Query strings are automatically parsed and available in req["query"]:
# URL: /search?q=solilang&page=1
def search(req: Any)
query = req["query"]["q"]; # "solilang"
page = req["query"]["page"]; # "1"
{
"status": 200,
"body": "Searching for: " + query
}
end
RESTful Resources
Generate standard CRUD routes automatically with resources():
resources("users")
| Method | Path | Action |
|---|---|---|
| GET | /users | index |
| GET | /users/new | new |
| POST | /users | create |
| GET | /users/:id | show |
| GET | /users/:id/edit | edit |
| PUT | /users/:id | update |
| DELETE | /users/:id | delete |
Named Routes
Every route registered via resources() automatically gets a pair of Rails-style helpers — <name>_path for the relative path and <name>_url for the absolute URL — defined as global functions in every worker. Use them anywhere you'd otherwise concatenate a URL by hand:
# In a controller
return redirect(post_path(post))
# In an ERB view
<a href="<%= edit_post_path(post) %>">Edit</a>
For resources("posts") you get:
| Helper | Returns |
|---|---|
| posts_path() | /posts |
| new_post_path() | /posts/new |
| post_path(post) | /posts/42 |
| edit_post_path(post) | /posts/42/edit |
Each one has a matching *_url variant (posts_url(), post_url(post), …) that returns the absolute form (https://example.com/posts/42).
How the argument is interpreted
post_path accepts the id in three shapes:
post_path(post) # reads post.id off the model instance
post_path(42) # raw int / string
post_path({"id": 42}) # explicit hash
For multi-param routes, pass either a hash or positional primitives in declaration order:
# pattern: /posts/:post_id/comments/:id
post_comment_path({"post_id": 5, "id": 9})
post_comment_path(5, 9)
Calling a helper without the params it needs raises a clear runtime error (post_path: missing param :id).
*_url and host resolution
Inside a request handler <name>_url reads the scheme and host from the current request (honouring X-Forwarded-Proto / X-Forwarded-Host for apps behind a proxy). Outside a request — e.g. from a background job, a CLI script, or a test — set SOLI_DEFAULT_URL_HOST (and optionally SOLI_DEFAULT_URL_SCHEME, defaults to http) and the helpers will use that. With neither in scope the helper raises <name>_url: cannot resolve host (no active request and SOLI_DEFAULT_URL_HOST not set).
Naming a custom route with name:
For one-off routes registered via get/post/put/delete/patch, attach a name with the name: keyword argument and the matching *_path / *_url helpers are generated for it too:
get("/about", "pages#about", name: "about")
# → about_path() → "/about"
# → about_url() → "https://example.com/about"
(name: rather than Rails' as: because as is a reserved keyword in Soli.)
Plural-to-singular limitations
resources() derives the member-route helper (<singular>_path) and the nested-resource param (:<singular>_id) from the resource name by a small built-in inflector. It handles three cases:
- Trailing
s—posts → post,users → user. ies → yafter a consonant —categories → category,parties → party,companies → company.- A short irregulars table —
people → person,men → man,women → woman,children → child,mice → mouse,geese → goose,feet → foot,teeth → tooth.
Anything else falls through unchanged, which can produce a confusing helper name or a collision with the collection helper. The two failure modes worth knowing:
- Words whose plural doesn't end in
sand aren't in the irregulars table (e.g.data,sheep,series,news) leave the singular equal to the plural, sodata_path()would mean both the collection and the member route. The member registration overwrites the collection in the route table — usually not what you want. Pick a different resource name (e.g.entries). - Words where
ies → yproduces nonsense are caught by the consonant guard (pies → pie, notpy), but exotic forms still won't round-trip. When in doubt, register the routes manually withget(..., name: "...").
Duplicate names
Registering two routes with the same name: is allowed; the last route to be added wins the helper. Hot reload follows the same rule because the lookup table is rebuilt from the live route list on every reload. There is no compile-time warning today, so audit config/routes.sl if <name>_path ever returns a path you weren't expecting.
Namespaces
Group routes under a common prefix, like /admin:
namespace("admin", ->
get("/dashboard", "admin#dashboard")
get("/users", "admin#users")
post("/users", "admin#create_user"))
Scoped Middleware
Apply middleware only to specific route blocks:
# Only /admin/* routes require authentication
middleware("authenticate", ->
get("/admin", "admin#index")
get("/admin/users", "admin#users"))
# Public routes - no auth needed
get("/", "home#index")
get("/about", "home#about")
Auto-routed attachments
Mount the standard show / create / destroy endpoints for a model field declared with
uploader(...):
uploads("contacts", "photo")
| Method | Path | Action | Notes |
|---|---|---|---|
| GET | /contacts/:id/photo | attachments#show | single, or first of many |
| POST | /contacts/:id/photo | attachments#create | multipart upload |
| DELETE | /contacts/:id/photo | attachments#destroy | clears the field (single) |
| GET | /contacts/:id/photo/:blob_id | attachments#show | multiple: true only |
| DELETE | /contacts/:id/photo/:blob_id | attachments#destroy | multiple: true only |
The shared AttachmentsController ships with the framework — you don't need to add any controller file. It recovers the resource and field segments from the request path, looks up the model class via find_model_class_by_collection, and delegates to the uploader's attach_/detach_ methods.
For image fields, the show endpoint also supports on-the-fly transforms via query params: ?square=200 (avatar shorthand), ?w=200&h=200&fit=cover, ?thumb=100, ?crop=10,20,300,200, ?rot=90&flipx=1, ?blur=2.5&bright=10&contrast=1.2, ?gray=1&invert=1, ?fmt=webp&q=80. See
Image transforms via URL
in the model docs for the full param list and the matching upload_url(model, field, options) builder.
To customize behavior (extra auth, alternative response shape, different storage), define your own class AttachmentsController < ApplicationController in app/controllers/attachments_controller.sl — user controllers load after the framework prelude so your version wins automatically.
Nested Resources
namespace("api", ->
resources("users", ->
resources("posts", ->
resources("comments")
)
)
)
# Creates: /api/users/:user_id/posts/:post_id/comments/:id
Nested Controllers
Controllers nested in subdirectories of app/controllers/ are addressed with a /-separated key:
# app/controllers/admin/merchants_controller.sl → AdminMerchantsController
get("/admin/merchants", "admin/merchants#index")
get("/admin/merchants/:id", "admin/merchants#show")
resources("/admin/merchants", "admin/merchants")
See Controllers → Nested Controller Directories for the full naming rules.
Auto-Derived Routes
Soli can automatically map routes to controllers based on naming conventions.
Defining a method named index in home_controller.sl automatically handles GET /home.
CSRF Protection
Soli rejects state-changing requests (POST/PUT/PATCH/DELETE) whose Origin or Referer header doesn't match the request Host. Cross-origin form-CSRF and same-site browser attacks return 403 before any controller runs. Safe methods (GET/HEAD/OPTIONS) and internal endpoints under /_* are exempt.
When neither Origin nor Referer is present, Soli branches on the Cookie header: a cookie-bearing request is rejected (it has no proof of same-site provenance and is exactly the stripped-UA / proxy bypass surface), while a cookie-less request is allowed (typical of non-browser API clients like cURL or mobile apps that don't ride a session cookie). Cookie-bearing endpoints that legitimately can't rely on Origin/Referer should use the skip_csrf route-level opt-out below.
For routes that legitimately accept cross-origin POSTs — Stripe webhooks, JSON APIs consumed by third-party services, OAuth callbacks — opt out per route via skip_csrf (for browser-facing APIs, prefer cors(), which opens the gate only for the origins it declares):
# config/routes.sl
# Exact path:
skip_csrf("/webhooks/stripe");
post("/webhooks/stripe", "webhooks#stripe");
# Whole prefix:
skip_csrf("/api/*");
post("/api/users", "api#users_create");
post("/api/orders", "api#orders_create");
The pattern is matched against the request path; /prefix/* covers /prefix and anything under /prefix/. To disable the check globally (API-only deployments where no cookie sessions exist), set SOLI_DISABLE_CSRF=true in the environment.
On top of the origin gate, Soli verifies per-form CSRF tokens: any state-changing request that carries a token — the _csrf_token field that form_with / csrf_field() embed, or the X-CSRF-Token header fed by csrf_meta_tag() — must match the session's token (constant-time compare) or it is rejected with 403 even when the Origin check passed. Requests without a token keep the origin posture; set SOLI_CSRF_TOKENS=require to make tokens mandatory for browser form posts. Both skip_csrf and SOLI_DISABLE_CSRF opt out of both layers. A POST carrying _method=PUT|PATCH|DELETE in its form body is routed as that verb, which is how resources(...) update/destroy routes work from plain HTML forms. See Forms & CSRF for the form builder that wires all of this up.
CORS
Declare cross-origin access per path with cors() — no middleware to write:
cors("/api/*", {
"origins": ["https://app.example.com"], # or "*" (the default)
"methods": ["GET", "POST", "PATCH", "DELETE"],
"headers": ["Content-Type", "Authorization"],
"expose": ["X-Request-Id"],
"credentials": true,
"max_age": 86400
})
- Preflights (
OPTIONS+Origin+Access-Control-Request-Method) are answered before routing with204and the allow headers — noOPTIONSroutes to define. - Every response of a CORS-managed path — rendered, streamed (SSE), static, or error — carries
Access-Control-Allow-Origin(plus credentials/expose headers) andVary: Origin. - The CSRF origin gate opens for an allowed
Originon that path — acors()declaration is a more precise cross-origin opt-in thanskip_csrf, because the origin is checked against the declared list. A cross-origin POST from an origin not in the list still gets403.
All options are optional. origins defaults to "*"; with "credentials": true the allow-origin header echoes the requesting origin (never *, per spec). An empty headers list echoes whatever the preflight asked for. Unknown option keys raise, so a typo can't silently weaken the policy. Re-declaring a pattern replaces its rule, so routes hot-reload picks up config edits.
Listing Routes (soli routes)
Print the app's full expanded route table from the command line — every route config/routes.sl (and mounted engines) registers, including each individual route a resources(...) call generates. Rows print in registration order, which is also the order the server matches requests against.
# App in the current directory
soli routes
# Explicit app folder
soli routes path/to/app
# Filter: case-insensitive match on method/path/handler/helper
soli routes -g posts
# Machine-readable JSON array (tooling, agents)
soli routes --json
METHOD PATH HANDLER HELPER MIDDLEWARE
GET / home#index root_path
GET /posts posts#index posts_path
POST /posts posts#create auth
GET /posts/:id posts#show post_path
GET /posts/:id/edit posts#edit edit_post_path
WS /ws/chat chat#handle
6 routes (1 websocket)
- HELPER is the generated named-route helper (
post_pathalso impliespost_url); blank rows have no name. - MIDDLEWARE lists middleware applied by route args or a
middleware("...", fn() ... end)scope. - WebSocket routes appear with the
WSmethod. --jsonemits[{"method", "path", "handler", "name", "middleware"}]objects — stable input for scripts and coding agents.
OpenAPI (SOLI_OPENAPI)
Set SOLI_OPENAPI=1 and the server exposes an OpenAPI 3 spec generated from your routes, plus a Scalar API-reference UI over it:
SOLI_OPENAPI=1 soli serve
# GET /openapi.json -> the spec
# GET /openapi -> browsable API reference
Every route becomes a path + method; a :id segment becomes a required {id} path parameter, the controller#action is the operationId/summary, and the controller is the tag so operations group by controller. SOLI_OPENAPI_TITLE sets the document title.
Soli actions take an untyped req and there's no annotation layer, so the spec is structural — the endpoints that exist and their path params, not request/response body schemas. The endpoints are opt-in (404 unless SOLI_OPENAPI is set) and, once on, served in every environment like /_metrics. The /openapi UI loads Scalar from a CDN (needs browser network access; the raw /openapi.json doesn't).