Routing and Request/Response Reference

This document covers everything you need to handle HTTP in JDA Forge: registering routes, reading request data, sending responses, middleware, WebSockets, Server-Sent Events, and static files.

Table of Contents

  1. Application setup
  2. Route registration
  3. Reading request data
  4. Sending responses
  5. Cookies
  6. Flash messages
  7. Sessions
  8. Middleware
  9. WebSocket
  10. Server-Sent Events (SSE)
  11. Static files
  12. Generating routes with scaffold
  13. Scopes — raw prefix groups
  14. Passing a model object to a path helper
  15. Before and after action filters
  16. Controller rescue handler

1. Application setup

Every Forge application starts with a call to app_new or app_new_config, followed by route registration and a blocking call to app_listen.

fn main() {
    load_env()
    let app = app_new_config(app_config())

    app_use(app, fn_addr(forge_logger))
    app_use(app, fn_addr(forge_session_start))
    app_use(app, fn_addr(forge_csrf))

    routes(app)   // generated from config/routes.jda

    app_listen(app, str_to_i32(forge_env_get("APP_PORT")))
}

routes(app) is generated by forge compile-routes from your config/routes.jda DSL file — you never write it by hand. app_listen blocks forever, serving requests on the given port.

2. Route registration

Routes are defined in config/routes.jda. forge build compiles the DSL into _build/routes.jda (path helpers + routes() function) — you never edit the generated file.

2.1 The routes DSL

config/routes.jda is the only routing file you ever edit:

root "posts#index"

resources "posts" do
  resources "comments"
end

namespace "admin" do
  resources "users"
end

scope "/api/v2" do
  get "/status" "api#status" as "api_v2_status"
end

get "/login"  "sessions#new"    as "login"
post "/login" "sessions#create"
delete "/logout" "sessions#delete" as "logout"

When you run forge build (or forge server, forge test), Forge automatically:

  1. Compiles config/routes.jda_build/routes.jda (path helpers + routes() function)
  2. Scans app/controllers/*.jda for handler functions → _build/controllers.jda

Both generated files are wired into the build by the Makefile. You never read or edit them. If an action defined in config/routes.jda has no matching handler function in a controller, that route is silently skipped at runtime.

Named parameters and wildcards work in any path:

get  "/users/:id"           "users#show"
get  "/files/*path"         "files#serve"
get  "/posts/:post_id/edit" "posts#edit"

The captured value is retrieved in the handler with ctx_param(ctx, "id").

2.2 resources — 7 RESTful routes

app.resources("posts")

Routes registered:

MethodPath
GET/posts
GET/posts/new
POST/posts
GET/posts/:id
GET/posts/:id/edit
PUT/posts/:id
DELETE/posts/:id

Returns a &ForgeScope prefixed at /posts/:post_id for nesting.

2.3 Nested resources

Chain .resources() off the returned scope:

app.resources("posts").resources("comments")

Three levels deep:

app.resources("users").resources("posts").resources("comments")

Registered paths: /users, /users/:user_id/posts, /users/:user_id/posts/:post_id/comments/…

2.4 Singular resource — app.resource

For resources with no index and no :id (profile, settings, cart):

app.resource("profile")

Routes registered: GET /profile/new, POST /profile, GET /profile, GET /profile/edit, PUT /profile, DELETE /profile.

2.5 Namespace — app.namespace

let admin = app.namespace("admin")
admin.resources("users")
admin.resources("posts")

Registered paths: /admin/users, /admin/users/:user_id, /admin/posts, etc.

2.6 Custom routes — app.get / app.post / app.put / app.delete

app.get("/login",  "sessions#new")
app.post("/login", "sessions#create")
app.delete("/logout", "sessions#delete")

These use the registry to look up the handler.

2.7 Concerns

A concern is a plain function that takes a &ForgeScope. Apply it to multiple parent scopes:

fn concern_commentable(s: &ForgeScope) {
    s.resources("comments")
}

fn routes(app: &ForgeApp) {
    concern_commentable(app.resources("posts"))
    concern_commentable(app.resources("articles"))
}

2.8 Explicit fn_addr form (power users)

When you need precise control — partial action sets, non-conventional handlers — use the explicit variants directly:

forge_resources_explicit(app, "posts",
    fn_addr(posts_index), fn_addr(posts_new),    fn_addr(posts_create),
    fn_addr(posts_show),  fn_addr(posts_edit),   fn_addr(posts_update),
    fn_addr(posts_delete))

forge_scope_resources_explicit(posts_scope, "comments",
    0, 0, fn_addr(comments_create),
    0, 0, 0, fn_addr(comments_delete))

Pass 0 for any handler to skip that route.

2.9 Route matching rules

  • Routes are matched in registration order. The first match wins.
  • Static segments take priority over named parameters: /posts/new registered before /posts/:id will match the literal new path before the parameter handler does.
  • Wildcard routes match last among otherwise equivalent prefixes.

3. Reading request data

All request-reading functions accept ctx and a string key. They return []i8 (a string slice). An empty slice (len == 0) means the value was absent.

3.1 URL parameters

let id = ctx_param(ctx, "id")

Returns the value captured by a :id segment. Returns an empty string if the route has no such parameter.

3.2 Query string

// GET /articles?page=3&per_page=20
let page     = ctx_query(ctx, "page")      // "3"
let per_page = ctx_query(ctx, "per_page")  // "20"
let missing  = ctx_query(ctx, "q")         // "" (len == 0)

3.3 Form data

// POST /login  (Content-Type: application/x-www-form-urlencoded)
let email    = ctx_form(ctx, "email")
let password = ctx_form(ctx, "password")

ctx_form parses an application/x-www-form-urlencoded body. For multipart form data, see ctx_body.

3.4 Request headers

let auth   = ctx_header(ctx, "Authorization")
let ct     = ctx_header(ctx, "Content-Type")
let tenant = ctx_header(ctx, "X-Tenant-Id")

Header names are case-insensitive.

3.5 Raw body

let raw = ctx_body(ctx)   // []i8

Use this to read JSON bodies, binary uploads, or any content type that is not URL-encoded form data.

Example — parse a JSON API request:

fn handle_api_create(ctx: i64) {
    let raw = ctx_body(ctx)
    if raw.len == 0 {
        ctx_bad_request(ctx, "empty body")
        ret
    }
    // pass raw to a JSON parsing function
    let title = json_get(raw, "title")
    // ...
}

3.6 Request metadata

let method = ctx_method(ctx)   // "GET", "POST", "DELETE", etc.
let path   = ctx_path(ctx)     // "/posts/42"
let ip     = ctx_ip(ctx)       // "203.0.113.5"

3.7 Shared context values

Middleware and handlers can pass arbitrary key/value strings through the request context:

ctx_set(ctx, "key", "value")
let val = ctx_get(ctx, "key")

The built-in forge_request_id middleware, for example, stores the request ID:

let rid = ctx_get(ctx, "request_id")

4. Sending responses

Every handler must send exactly one response. Calling a response function does not automatically stop execution — use ret after it.

4.1 HTML

ctx_html(ctx, 200, "<h1>Hello</h1>")

Sets Content-Type: text/html; charset=utf-8.

4.2 JSON

ctx_json(ctx, 200, "{\"id\": 1, \"title\": \"Hello\"}")

Sets Content-Type: application/json.

4.3 Plain text

ctx_text(ctx, 200, "pong")

Sets Content-Type: text/plain; charset=utf-8.

4.4 Redirects

ctx_redirect(ctx, "/posts")           // 302 Found
ctx_redirect_perm(ctx, "/new/path")   // 301 Moved Permanently

4.5 Status helpers

Each helper sends an empty body (or a short error message) with the appropriate status code.

FunctionStatusNotes
ctx_not_found(ctx)404
ctx_forbidden(ctx)403
ctx_unauthorized(ctx)401
ctx_bad_request(ctx, msg)400msg is included in the response body
ctx_unprocessable(ctx, json)422json should be a JSON error object string
ctx_too_many_requests(ctx)429
fn handle_api_show(ctx: i64) {
    let id = ctx_param(ctx, "id")
    let row = post_find(id)
    if row == 0 {
        ctx_not_found(ctx)
        ret
    }
    ctx_json(ctx, 200, post_to_json(row))
}

4.6 Custom response headers

Set arbitrary response headers before calling a response function:

ctx_set_header(ctx, "X-My-Header", "value")
ctx_set_header(ctx, "Cache-Control", "no-store")
ctx_json(ctx, 200, payload)

4.7 A complete CRUD example

Action functions use bare names. forge compile-routes reads the filename (posts_controller.jda) to derive the controller name and prefixes each action in the generated build, so fn index becomes fn posts_index without you writing it.

// app/controllers/posts_controller.jda

fn index(ctx: i64) {
    ctx_render(ctx, view_posts_index(ctx, post_published()))
}

fn new(ctx: i64) {
    ctx_render(ctx, view_posts_new(ctx))
}

fn create(ctx: i64) {
    if post_create_from(ctx_permit(ctx, "title, body, author")) {
        ctx_flash_set(ctx, "notice", "Post created.")
        ctx_redirect(ctx, posts_path)
        ret
    }
    ctx_save_errors(ctx)
    ctx_redirect(ctx, new_post_path)
}

fn show(ctx: i64) {
    let id   = ctx_param(ctx, "id")
    let post = post_find(id)
    if post.count == 0 { ctx_not_found(ctx)  ret }
    ctx_render(ctx, view_posts_show(ctx, post))
}

fn edit(ctx: i64) {
    let id   = ctx_param(ctx, "id")
    let post = post_find(id)
    if post.count == 0 { ctx_not_found(ctx)  ret }
    ctx_render(ctx, view_posts_edit(ctx, post))
}

fn update(ctx: i64) {
    let id = ctx_param(ctx, "id")
    if post_update_from(id, ctx_permit(ctx, "title, body, author")) {
        ctx_flash_set(ctx, "notice", "Post updated.")
        ctx_redirect(ctx, post_path(id))
        ret
    }
    ctx_save_errors(ctx)
    ctx_redirect(ctx, edit_post_path(id))
}

fn delete(ctx: i64) {
    post_delete(ctx_param(ctx, "id"))
    ctx_flash_set(ctx, "notice", "Post deleted.")
    ctx_redirect(ctx, posts_path)
}

Validations fire automatically inside post_create_from / post_update_from — no manual validate call needed. ctx_save_errors stores failure details in the flash for the next request. Controllers use path helper constants and functions, never hard-coded strings.

5. Cookies

// Set a cookie: name, value, max-age in seconds
ctx_set_cookie(ctx, "theme", "dark", 2592000)   // 30 days

// Read a cookie
let theme = ctx_get_cookie(ctx, "theme")
if theme.len == 0 { theme = "light" }

ctx_set_cookie adds a Set-Cookie header to the response. Call it before the response function.

fn handle_preferences_save(ctx: i64) {
    let theme = ctx_form(ctx, "theme")
    ctx_set_cookie(ctx, "theme", theme, 31536000)   // 1 year
    ctx_redirect(ctx, "/preferences")
}

Cookie values are plain strings. For tamper-proof values (e.g., a user ID that must not be forged), use sessions instead.

6. Flash messages

Flash messages survive exactly one redirect. They are stored in the session, read on the next request, and then deleted. The session middleware must be present in the stack.

// Set a flash before redirecting
ctx_flash_set(ctx, "notice", "Your changes were saved.")
ctx_flash_set(ctx, "alert",  "Something went wrong.")
ctx_redirect(ctx, "/dashboard")

// Read the flash in the next request's handler (or in the layout template)
let notice = ctx_flash_get(ctx, "notice")
let alert  = ctx_flash_get(ctx, "alert")

Conventions used throughout Forge examples:

KeyMeaning
noticeSuccess or informational message
alertError or warning

Typical layout usage:

fn layout(body: []i8, ctx: i64) -> []i8 {
    let notice = ctx_flash_get(ctx, "notice")
    let alert  = ctx_flash_get(ctx, "alert")
    // include notice and alert in the rendered HTML
    ret "<html>..." + notice_html(notice) + alert_html(alert) + body + "</html>"
}

7. Sessions

Sessions require the forge_session_start middleware. Add it to the stack before any handler that needs session data.

app_use(app, fn_addr(forge_session_start))

Session data is stored server-side (in memory or a backing store depending on configuration) and keyed by a cookie sent to the browser.

// Store a value
ctx_session_set(ctx, "user_id", "42")

// Read a value
let user_id = ctx_session_get(ctx, "user_id")
if user_id.len == 0 {
    ctx_redirect(ctx, "/login")
    ret
}

// Remove one key
ctx_session_delete(ctx, "user_id")

// Destroy the entire session (logout)
ctx_session_clear(ctx)
ctx_redirect(ctx, "/login")

Authentication pattern

fn handle_login_create(ctx: i64) {
    let email    = ctx_form(ctx, "email")
    let password = ctx_form(ctx, "password")

    let user_id = user_authenticate(email, password)
    if user_id.len == 0 {
        ctx_flash_set(ctx, "alert", "Invalid email or password.")
        ctx_redirect(ctx, "/login")
        ret
    }

    ctx_session_set(ctx, "user_id", user_id)
    ctx_flash_set(ctx, "notice", "Welcome back.")
    ctx_redirect(ctx, "/dashboard")
}

fn handle_logout(ctx: i64) {
    ctx_session_clear(ctx)
    ctx_redirect(ctx, "/login")
}
fn require_login(ctx: i64) {
    let uid = ctx_session_get(ctx, "user_id")
    if uid.len == 0 {
        ctx_redirect(ctx, "/login")
        ret
    }
    ctx_set(ctx, "current_user_id", uid)
}

Register require_login as middleware (globally or only for protected routes) before protected handlers run.

8. Middleware

Middleware functions run for every request, in registration order, before the matched route handler.

8.1 Registering middleware

app_use(app, fn_addr(my_middleware))

app_use must be called before app_listen. Middleware runs in the order it was registered.

8.2 Built-in middleware

FunctionEffect
forge_loggerLogs method, path, status, and duration for every request
forge_request_idAdds X-Request-Id to every response; stores value in ctx under "request_id"
forge_secure_headersSets HSTS, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, and a default CSP
forge_session_startInitialises the session cookie; required for flash messages and CSRF protection
forge_csrfBlocks state-changing requests (POST, PUT, PATCH, DELETE) without a valid CSRF token; must come after forge_session_start
forge_rate_limitReturns 429 after 100 requests per minute from the same IP
forge_corsAdds permissive CORS headers; suitable for development; configure explicitly for production
forge_jwt_authValidates a Bearer token from the Authorization header
forge_basic_authValidates HTTP Basic credentials
forge_compressCompresses responses with gzip when the client sends Accept-Encoding: gzip
app_use(app, fn_addr(forge_logger))          // always first — logs everything
app_use(app, fn_addr(forge_request_id))      // before logger output if you log request IDs
app_use(app, fn_addr(forge_secure_headers))  // early — sets security headers unconditionally
app_use(app, fn_addr(forge_session_start))   // before CSRF and flash
app_use(app, fn_addr(forge_csrf))            // after session
app_use(app, fn_addr(forge_rate_limit))      // after request ID so limits are attributable

Order matters:

  • forge_session_start must come before forge_csrf — the CSRF middleware reads the token from the session.
  • forge_logger should come before anything that might short-circuit the request (e.g., auth or rate-limit middleware) so that rejected requests are still logged.
  • Authentication middleware (forge_jwt_auth, forge_basic_auth) should come late enough that forge_logger and forge_secure_headers have already run.

8.4 Writing custom middleware

A middleware function has the same signature as a handler. Forge calls it automatically before the route handler; there is no explicit next() call.

fn my_middleware(ctx: i64) {
    // Code here runs before the handler.
    // Read from the request, set ctx values, or short-circuit with a response.
}

To short-circuit (stop the chain), send a response and return:

fn require_api_key(ctx: i64) {
    let key = ctx_header(ctx, "X-Api-Key")
    if key.len == 0 {
        ctx_unauthorized(ctx)
        ret
    }
    if !api_key_valid(key) {
        ctx_forbidden(ctx)
        ret
    }
    // No response sent — Forge continues to the next middleware / handler.
}

To pass data to the handler, use ctx_set:

fn tenant_middleware(ctx: i64) {
    let tenant = ctx_header(ctx, "X-Tenant-Id")
    if tenant.len == 0 {
        ctx_bad_request(ctx, "missing X-Tenant-Id header")
        ret
    }
    ctx_set(ctx, "tenant", tenant)
}

fn handle_data(ctx: i64) {
    let tenant = ctx_get(ctx, "tenant")
    // use tenant ...
}

8.5 Example: logging with request ID

fn audit_log(ctx: i64) {
    let rid    = ctx_get(ctx, "request_id")
    let method = ctx_method(ctx)
    let path   = ctx_path(ctx)
    let ip     = ctx_ip(ctx)
    // write to your audit log
    audit_write(rid, method, path, ip)
}

// in main:
app_use(app, fn_addr(forge_logger))
app_use(app, fn_addr(forge_request_id))
app_use(app, fn_addr(audit_log))         // request_id is already set

9. WebSocket

Upgrade an HTTP GET request to a WebSocket connection with forge_ws_upgrade. The function returns a connection handle on success (>= 0) or a negative value on failure.

fn handle_ws(ctx: i64) {
    let conn = forge_ws_upgrade(ctx)
    if conn < 0 { ret }

    loop {
        let msg = forge_ws_read(conn)
        if msg.len == 0 {
            forge_ws_close(conn)
            ret
        }
        forge_ws_write(conn, "echo: " + msg)
    }
}

Add the route in config/routes.jda:

get "/ws" "ws#handle"

| Function | Description |
|---|---|
| `forge_ws_upgrade(ctx)` | Performs the HTTP upgrade handshake; returns connection handle |
| `forge_ws_read(conn)` | Blocks until a frame arrives; returns `[]i8` with the message text; returns empty slice on close or error |
| `forge_ws_write(conn, msg)` | Sends a text frame |
| `forge_ws_close(conn)` | Closes the connection |

`forge_ws_read` returns an empty slice (`len == 0`) when the client closes the connection or a network error occurs. Always check and close before returning.

### Chat broadcast example

fn handle_chat(ctx: i64) { let conn = forge_ws_upgrade(ctx) if conn < 0 { ret }

ws_pool_add(conn)

loop {
    let msg = forge_ws_read(conn)
    if msg.len == 0 {
        ws_pool_remove(conn)
        forge_ws_close(conn)
        ret
    }
    ws_pool_broadcast(msg)
}

}



## 10. Server-Sent Events (SSE)

SSE lets a server push a stream of text events to the browser over a single long-lived HTTP connection.

fn handle_sse(ctx: i64) { forge_sse_start(ctx)

let i = 0
loop {
    forge_sse_send(ctx, "update", i64_to_str(i))
    i = i + 1
    forge_sleep_ms(1000)
}

}


Add the route in `config/routes.jda`:

get “/events” “sse#handle”

```

| Function | Description |
|---|---|
| `forge_sse_start(ctx)` | Sends the SSE headers (`Content-Type: text/event-stream`, `Cache-Control: no-cache`) and flushes |
| `forge_sse_send(ctx, event, data)` | Sends one event with the given event name and data string |
| `forge_sleep_ms(ms)` | Sleeps for `ms` milliseconds (used to pace the stream) |

Each call to `forge_sse_send` writes:
jda

event: update data: 0


The browser's `EventSource` API receives these as named events. Clients reconnect automatically when the connection drops.


## 11. Static files

Serve a local directory under a URL prefix:

<pre class="forge-code code-block code-sm"><code>forge_static(app, <span class="str">"/static"</span>, <span class="str">"public/"</span>)</code></pre>

This registers a wildcard route internally. Any request whose path begins with `/static` maps to the `public/` directory:

<pre class="forge-code code-block code-sm"><code>GET /static/app.js          =&gt;  public/app.js
GET /static/images/logo.png =&gt;  public/images/logo.png</code></pre>

Call `forge_static` after middleware registration but before `app_listen`. Multiple static mounts are supported:

<pre class="forge-code code-block code-sm"><code>forge_static(app, <span class="str">"/static"</span>,  <span class="str">"public/"</span>)
forge_static(app, <span class="str">"/uploads"</span>, <span class="str">"storage/uploads/"</span>)</code></pre>

Forge sets `Content-Type` based on the file extension and serves `Last-Modified` and `ETag` headers for conditional GET support. Missing files return 404.


## 12. Generating routes with scaffold

The scaffold generator creates a complete vertical slice — migration, model, controller, views, and tests — from a single command:

```bash
forge generate scaffold Post title:string body:string author:string

This creates:

db/migrate/001_create_posts.sql
app/models/post.jda
app/controllers/posts_controller.jda
app/views/posts/index.html.jda
app/views/posts/show.html.jda
app/views/posts/new.html.jda
app/views/posts/edit.html.jda
test/test_posts.jda

It also appends resources "posts" to config/routes.jda. The next forge build auto-scans controllers and wires everything. No manual registration needed.

Generated controller actions for a Post resource. Write bare names in the controller file — compile-routes prefixes them:

File functionCompiled asMethodPath
fn indexposts_indexGET/posts
fn newposts_newGET/posts/new
fn createposts_createPOST/posts
fn showposts_showGET/posts/:id
fn editposts_editGET/posts/:id/edit
fn updateposts_updatePUT/posts/:id
fn deleteposts_deleteDELETE/posts/:id

Path helpers

forge build generates path helpers in config/routes.jda from your config/routes.jda DSL:

// Zero-arg paths — constants, no call needed
let posts_path: []i8    = "/posts"
let new_post_path: []i8 = "/posts/new"

// Id-taking paths — functions
fn post_path(id: []i8) -> []i8      { ret forge_path_id("posts", id) }
fn edit_post_path(id: []i8) -> []i8 { ret forge_path_edit("posts", id) }

Use them in controllers, views, and tests — never hard-code path strings:

// In a controller
ctx_redirect(ctx, posts_path)
ctx_redirect(ctx, post_path(id))

// In a test
forge_get(posts_path).ok(200)
forge_delete(post_path("1")).redirect()

For nested resources, define a one-line helper using forge_nested_path*:

fn post_comments_path(post_id: []i8) -> []i8      { ret forge_nested_path("posts", post_id, "comments") }
fn post_comment_path(post_id: []i8, id: []i8) -> []i8 { ret forge_nested_path_id("posts", post_id, "comments", id) }

All forge_path* and forge_nested_path* functions:

FunctionResult
forge_path("posts")/posts
forge_path_new("posts")/posts/new
forge_path_id("posts", id)/posts/<id>
forge_path_edit("posts", id)/posts/<id>/edit
forge_nested_path(par, pid, child)/par/pid/child
forge_nested_path_new(par, pid, child)/par/pid/child/new
forge_nested_path_id(par, pid, child, id)/par/pid/child/id
forge_nested_path_edit(par, pid, child, id)/par/pid/child/id/edit

Naming conventions — enforced by scaffold

Scaffold enforces these conventions and raises an error if they are violated:

LayerFileFunction prefix
Controllerapp/controllers/posts_controller.jdaposts_
Modelapp/models/post.jdapost_
Viewapp/views/posts/index.html.jdaview_posts_

Resource names must be PascalCase: forge generate scaffold Post ✓ — forge generate scaffold post is an error.

13. Scopes — raw prefix groups

forge_scope wraps an app with a path prefix. Use it when forge_resources / forge_namespace are not the right fit — arbitrary prefix, non-standard method mix, or adding extra routes alongside a resource.

let api = forge_scope(app, "/api/v2")
api.get("/status",        fn_addr(api_status))
api.get("/users/:id",     fn_addr(api_user_show))
api.post("/users",        fn_addr(api_user_create))

Deeply nested scopes with forge_scope_nested

Build scopes from existing scopes for multi-level nesting:

fn routes(app: &ForgeApp) {
    let users    = forge_scope(app, "/users/:user_id")
    let posts    = forge_scope_nested(users,  "/posts/:post_id")
    let comments = forge_scope_nested(posts,  "/comments/:comment_id")

    users.get("/posts",        fn_addr(user_posts_index))
    users.post("/posts",       fn_addr(user_posts_create))

    posts.get("/comments",     fn_addr(post_comments_index))
    posts.post("/comments",    fn_addr(post_comments_create))

    comments.post("/likes",        fn_addr(comment_likes_create))
    comments.delete("/likes/:id",  fn_addr(comment_likes_delete))
}

Registered paths: GET /users/:user_id/posts, POST /users/:user_id/posts/:post_id/comments, DELETE /users/:user_id/posts/:post_id/comments/:comment_id/likes/:id, etc.

Path helpers for namespaced and nested routes

// Namespaced — flat constants and forge_path_id
let admin_posts_path: []i8 = "/admin/posts"
fn admin_post_path(id: []i8) -> []i8 { ret forge_path_id("admin/posts", id) }

// Nested — one-liners using forge_nested_path*
fn post_comments_path(post_id: []i8) -> []i8 {
    ret forge_nested_path("posts", post_id, "comments")
}
fn post_comment_path(post_id: []i8, id: []i8) -> []i8 {
    ret forge_nested_path_id("posts", post_id, "comments", id)
}
fn new_post_comment_path(post_id: []i8) -> []i8 {
    ret forge_nested_path_new("posts", post_id, "comments")
}
fn edit_post_comment_path(post_id: []i8, id: []i8) -> []i8 {
    ret forge_nested_path_edit("posts", post_id, "comments", id)
}

Scope method reference

FunctionWhat it does
forge_scope(app, "/prefix")Scope at arbitrary prefix
forge_scope_nested(scope, "/suffix")Child scope — concatenates prefix + suffix
forge_namespace(app, "admin")Scope all routes under /admin
forge_resources(app, "posts", ...)7 routes + returns &ForgeScope at /posts/:post_id
forge_scope_resources(scope, "comments", ...)7 nested routes + returns deeper &ForgeScope
forge_resource(app, "profile", ...)6 singular routes (no index, no :id)
forge_scope_resource(scope, "profile", ...)Singular resource within a scope

14. Passing a model object to a path helper

post_path takes a []i8 id. When you have a &ForgeResult from a query, use .id() via UFCS to extract the id column value:

let post = post_find(id)
let url  = post_path(post.id())      // post.id() = forge_result_id(post) = forge_result_col(post, 0, "id")

ctx_redirect(ctx, post_path(post.id()))

forge_result_id is the underlying function; .id() is the UFCS shorthand. Works on any &ForgeResult — the column looked up is always "id".

For tests, post_path(post.id()) reads naturally with the chainable DSL:

fn test_post_show() {
    test_setup()
    let post = post_find("1")
    forge_get(post_path(post.id())).ok(200).has("Hello World")
}

15. Before and after action filters

Controller filters run before or after the action function. They are registered per controller in a setup function called from main().

Before filters

forge_ctrl_before(ctrl, fn_ptr, only) — run before the listed actions. Pass an empty string for only to run before every action.

fn require_login(ctx: i64) {
    if ctx_session_get(ctx, "user_id").len == 0 {
        ctx_redirect(ctx, "/login")
    }
}

fn set_post(ctx: i64) {
    let post = post_find(ctx_param(ctx, "id"))
    if forge_result_empty(post) { ctx_not_found(ctx)  ret }
    ctx_set(ctx, "post", post as i64)
}

fn posts_before_actions() {
    let ctrl = forge_ctrl_new()
    // Bare names — compile-routes rewrites fn_addr(set_post) to fn_addr(posts_set_post)
    forge_ctrl_before(ctrl, fn_addr(set_post),      "show, edit, update, delete")
    forge_ctrl_before_except(ctrl, fn_addr(require_login), "index, show")
    forge_ctrl_register("posts", ctrl)
}

forge_ctrl_before_except(ctrl, fn_ptr, except) — run before every action except the listed ones. Useful for “require login everywhere but the public pages”.

After filters

forge_ctrl_after(ctrl, fn_ptr, only) — run after the action completes (regardless of whether it sent a response). Pass an empty string for only to run after every action.

fn log_action(ctx: i64) {
    forge_log_ctx_info(ctx, "action completed")
}

fn posts_before_actions() {
    let ctrl = forge_ctrl_new()
    forge_ctrl_before(ctrl, fn_addr(set_post),   "show, edit, update, delete")
    forge_ctrl_after (ctrl, fn_addr(log_action), "")
    forge_ctrl_register("posts", ctrl)
}

Filter API

FunctionWhen it runs
forge_ctrl_before(ctrl, fn_ptr, only)Before the action; only is comma-separated list, empty = all
forge_ctrl_before_except(ctrl, fn_ptr, except)Before the action for all actions not in except
forge_ctrl_after(ctrl, fn_ptr, only)After the action; only is comma-separated list, empty = all

16. Controller rescue handler

forge_ctrl_rescue(ctrl, fn_ptr) registers a fallback that runs when the action exits without sending a response — a controller-level fallback for unhandled errors. Use it for a consistent error page across a whole controller without repeating error-handling logic in every action.

fn rescue(ctx: i64) {
    forge_log_ctx_error(ctx, "unhandled error in posts controller")
    ctx_html(ctx, 500, "<h1>Something went wrong</h1>")
}

fn posts_before_actions() {
    let ctrl = forge_ctrl_new()
    forge_ctrl_before (ctrl, fn_addr(set_post), "show, edit, update, delete")
    forge_ctrl_rescue (ctrl, fn_addr(rescue))
    forge_ctrl_register("posts", ctrl)
}

The rescue handler is called after all after-filters. It receives the same ctx as the action, so you can inspect request state (method, path, headers) to decide on the response.

If the action already sent a response (via ctx_render, ctx_redirect, etc.) the rescue handler is not called — it only fires on the no-response path.