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:

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

Next Steps