ESC
Type to search...
S
Soli Docs

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:

  1. splat parameter
  2. path, filepath, filename, action, or resource
  3. 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/usersindex
GET/users/newnew
POST/userscreate
GET/users/:idshow
GET/users/:id/editedit
PUT/users/:idupdate
DELETE/users/:iddelete

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 sposts → post, users → user.
  • ies → y after a consonantcategories → category, parties → party, companies → company.
  • A short irregulars tablepeople → 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 s and aren't in the irregulars table (e.g. data, sheep, series, news) leave the singular equal to the plural, so data_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 → y produces nonsense are caught by the consonant guard (pies → pie, not py), but exotic forms still won't round-trip. When in doubt, register the routes manually with get(..., 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/photoattachments#showsingle, or first of many
POST/contacts/:id/photoattachments#createmultipart upload
DELETE/contacts/:id/photoattachments#destroyclears the field (single)
GET/contacts/:id/photo/:blob_idattachments#showmultiple: true only
DELETE/contacts/:id/photo/:blob_idattachments#destroymultiple: 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 with 204 and the allow headers — no OPTIONS routes to define.
  • Every response of a CORS-managed path — rendered, streamed (SSE), static, or error — carries Access-Control-Allow-Origin (plus credentials/expose headers) and Vary: Origin.
  • The CSRF origin gate opens for an allowed Origin on that path — a cors() declaration is a more precise cross-origin opt-in than skip_csrf, because the origin is checked against the declared list. A cross-origin POST from an origin not in the list still gets 403.

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_path also implies post_url); blank rows have no name.
  • MIDDLEWARE lists middleware applied by route args or a middleware("...", fn() ... end) scope.
  • WebSocket routes appear with the WS method.
  • --json emits [{"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).

Next Steps