ESC
Type to search...
S
Soli Docs

Controllers

Controllers are the heart of your application. They handle HTTP requests, process data, and determine the response. Soli supports both modern class-based controllers and simple function-based handlers. The request hash is automatically available as req — no parameter declaration needed. When no explicit render is called, the matching view template is auto-rendered with all @ instance variables exposed as view locals.

Class-Based Controllers

Recommended for most applications. They provide structure, inheritance, and powerful hooks like before_action.

class PostsController < ApplicationController
  static {
    this.layout = "layouts/posts";
  }

  def index
    @title = "All Posts"
    @posts = Post.all
  end

  def show
    @post = Post.find(params["id"])    # raises 404 if not found
  end
end

Lifecycle Hooks

Execute code before or after actions. Perfect for authentication or data loading.

this.layout

Set the layout template for all actions

this.before_action

Run code before action executes

this.after_action

Run code after action executes

class PostsController < ApplicationController
  static {
    # Set layout for all actions
    this.layout = "layouts/posts";

    # Run before ALL actions
    this.before_action = fn(req) {
      print("Processing: " + req.path);
      req
    }

    # Run before SPECIFIC actions only
    this.before_action(:show, :edit) = fn(req) {
      @post = Post.find(params["id"])    # raises 404 if not found
      req
    }

    # Run after SPECIFIC actions
    this.after_action(:create, :update) = fn(req, response) {
      # Log activity after create/update
      print("Action completed");
      response
    }
  }

  # ... actions ...
end

No imports needed for models. Files under app/models/ are auto-loaded by soli serve and the REPL, so classes like User, Post, etc. are available inside controller actions without import. (If you run a controller file standalone via soli run, add the imports back.) The linter warns via style/redundant-model-import.

Controller Inheritance

Controllers support multi-level inheritance. Create a base controller to share logic across multiple controllers. Hooks like before_action, after_action, and layout are automatically inherited by child controllers.

class BaseController < Controller
  static {
    this.layout = "application";

    this.before_action = fn(req) {
      user_id = req.session["user_id"];
      if user_id != null {
        req["current_user"] = User.find(user_id);
      }
      req
    }
  }

  # Shared helper available to all child controllers
  def _current_user
    this.req["current_user"]
  end
end
# Inherits layout, before_action, and _current_user from BaseController
class PostsController < BaseController
  static {
    # Add controller-specific hook (runs after parent's before_action)
    this.before_action(:show, :edit) = fn(req) {
      @post = Post.find(params["id"])    # raises 404 if not found
      req
    }
  }

  def index
    @posts = Post.all
  end

  def show
    # @post is set by before_action — template renders posts/show
  end
end
# Even deeper inheritance works
class AdminController < BaseController
  static {
    this.layout = "admin";  # Override parent layout

    this.before_action = fn(req) {
      return redirect("/login") if req["current_user"] == null;
      req
    }
  }
end

Inheritance Rules

  • Methods are inherited and can be overridden. Use super.method() to call the parent version.
  • before_action / after_action hooks are inherited from parent controllers. Child hooks run after parent hooks.
  • layout is inherited if the child doesn't set its own.
  • Fields declared in parent classes are available in child instances.

Nested Controller Directories

Group related controllers into subdirectories under app/controllers/. The directory path becomes part of the controller key, the route base path, and the class name.

app/controllers/
├── home_controller.sl                # HomeController            → /
├── users_controller.sl               # UsersController           → /users
└── admin/
  ├── merchants_controller.sl       # AdminMerchantsController  → /admin/merchants
  └── user_profiles_controller.sl   # AdminUserProfilesController → /admin/user_profiles

Both _ and / act as word separators in the class name, so admin/user_profiles_controller.sl becomes AdminUserProfilesController — there is no :: namespacing.

Reference nested controllers from config/routes.sl using the same controller#action syntax with a /-separated key:

get("/admin/merchants", "admin/merchants#index")
get("/admin/merchants/:id", "admin/merchants#show")

# Or with resources()
resources("/admin/merchants", "admin/merchants")

Hot reload

Subdirectories are watched recursively in dev mode, so adding or editing a nested controller triggers reload like any top-level controller.

Function-Based Controllers

Great for simple APIs or microservices where you don't need the full power of classes.

# GET /health
def health
  {
    "status": 200,
    "headers": {"Content-Type": "application/json"},
    "body": "{\"status\":\"ok\"}"
  }
end

The Request Object

Access all HTTP request details through the req parameter.

req.params

Route parameters & query strings

req.body

Raw request body content

req.headers

HTTP headers map

req.session

User session data storage

req.cookies

Parsed cookies from the Cookie header, available globally as cookies

req["remote_addr"]

Actual TCP peer IP. Used by rate_limit for buckets; honored as the trusted client identifier when enable_trust_proxy() is off, otherwise the rightmost X-Forwarded-For entry wins.

Cookies

Response Types

render(template, data?)

Render an HTML view template with optional data.

render("home/index", { "title": "Welcome" })
render_json(data, status?)

Render a JSON response with automatic Content-Type: application/json header. Data is automatically serialized to JSON.

render_json({ "users": users })
render_json({ "error": "Not found" }, 404)

Security — instance serialisation. render_json(instance) (and any code path that JSON-stringifies a model record) omits sensitive fields by default. Names matching password*, *_token, *_digest, *_secret, or *_hash are dropped, as are _-prefixed framework internals (_errors, _text, …). Standard model metadata (_key, _id, _rev, _created_at, _updated_at) is still included. To expose a field whose name matches a pattern, build the response shape explicitly: render_json({ "id": user._key, "email": user.email }).

For a reusable model-side shape, define an as_json method on the Model subclass — same convention as Rails' ActiveModel::Serializers#as_json:

class User < Model
  def as_json
    return { "id": this._key, "email": this.email, "name": this.name }
  end
end

# controller — render_json auto-dispatches through the user method:
render_json(user)
# equivalent to: render_json(user.as_json())

When render_json receives an Instance whose class declares def as_json, the framework calls the method first and forwards the resulting Hash to render_json. Models without an as_json method fall back to the default-deny filter described above.

render_text(text, status?)

Render a plain text response with automatic Content-Type: text/plain header.

render_text("pong")
render_text("Not allowed", 403)
redirect(url)

Create a redirect response (302 Found) to a local absolute path. External URLs are rejected to prevent open redirects.

redirect("/login")
redirect("/posts/" + post["id"])

Use redirect_external(url) only for trusted external destinations such as OAuth providers.

redirect_external("https://github.com/login/oauth/authorize")

Pass :back to send the user where they came from. The Referer header is honored only when its scheme and host match the current request; external or missing referers fall back to /.

redirect(:back)
halt(status, message)

Return an error response with the given HTTP status code and message.

halt(404, "Page Not Found")
halt(403, "Forbidden")
respond_to(req, block)

Rails-style content negotiation. Picks the right branch based on URL extension, ?format=, HTMX/XHR headers, or the Accept header (with q-values). Falls back to 406 Not Acceptable if nothing matches and no any branch is registered.

def show
  post = Post.find(req["params"]["id"])
  respond_to(req, fn(format) {
    format.html(fn()  render("posts/show", { "post": post }))
    format.json(fn()  render_json(post))
    format.csv(fn()   render_csv_for(post))
    format.pdf(fn()   render_pdf_for(post))
    format.excel(fn() render_xlsx_for(post))
    format.htmx(fn()  render("posts/_show_partial", { "post": post }, { "layout": false }))
    format.xhr(fn()   render_json({ "id": post["id"] }))
    format.any(fn()   render("posts/show", { "post": post }))   # optional catch-all
  })
end

A terser hash form is also supported:

respond_to(req, {
  "html": fn() render("posts/show", { "post": post }),
  "json": fn() render_json(post)
})

Format detection priority

  1. HX-Request: true header → htmx branch.
  2. X-Requested-With: XMLHttpRequest header → xhr branch.
  3. URL extension: .html, .json, .xml, .csv, .pdf, .xlsx/.xls, .txt.
  4. ?format=… query parameter.
  5. Accept header — parsed with q-values; */* falls through to the first registered handler.

Available format tokens

html, json, xml, csv, pdf, excel, htmx, xhr, text, any. Registering any makes it the catch-all (no 406). Last registration wins on duplicates.

Note: header keys in req["headers"] are lowercased — read req["headers"]["accept"], not "Accept".

Request-Context Helpers in Views

These helpers read the current request directly — no need to plumb current_path or current_method through the data hash. They're available in every template (views, layouts, partials).

current_path()

Request pathname, e.g. "/users". null outside a request.

current_method()

HTTP method, e.g. "GET". null outside a request.

current_path?(p)

true if the current path equals p exactly. Use for active-link checks.

<nav>
  <a href="/users" class="<%= current_path?("/users") ? "active" : "" %>">Users</a>
  <a href="/posts" class="<%= current_path?("/posts") ? "active" : "" %>">Posts</a>
</nav>

<p>You are viewing <%= current_path() %> (<%= current_method() %>).</p>

For prefix matches (e.g. any path under /users), compose with current_path().starts_with("/users").

Pro Tip: Keep Controllers Thin

Delegate complex business logic to Models or Service objects. Controllers should primarily focus on handling the HTTP request and selecting the correct response.

Best Practices

  • Use class-based controllers for better organization and reuse.
  • Create a base ApplicationController to share logic like auth across all controllers.
  • Use before_action to load resources and DRY up your code.
  • Prefix private helper methods with _ to prevent them from becoming routes.

Next Steps