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:
# 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.