Skip to content

Routes

Routes are the foundation of any Tova server. They map HTTP methods and URL paths to handler functions that process requests and return responses.

Route Declaration

Declare routes with the route keyword, followed by an HTTP method, a path, and a handler function:

tova
server {
  fn get_users() -> [User] {
    UserModel.all()
  }

  fn create_user(req) {
    UserModel.create(req.body)
  }

  route GET "/api/users" => get_users
  route POST "/api/users" => create_user
}

HTTP Methods

Tova supports all standard HTTP methods:

MethodTypical Use
GETRetrieve resources
POSTCreate resources
PUTReplace resources
DELETERemove resources
PATCHPartially update resources
HEADRetrieve headers only
OPTIONSPreflight / capability checks
tova
server {
  route GET "/api/users" => list_users
  route POST "/api/users" => create_user
  route PUT "/api/users/:id" => replace_user
  route DELETE "/api/users/:id" => delete_user
  route PATCH "/api/users/:id" => patch_user
  route HEAD "/api/users" => users_head
  route OPTIONS "/api/users" => users_options
}

Path Parameters

Use :param syntax to capture dynamic segments from the URL. Parameters are extracted and passed as arguments to the handler:

tova
route GET "/users/:id" => get_user

fn get_user(id: String) {
  UserModel.find(id)
}

With type annotations, path parameters are automatically validated and converted:

tova
fn get_user(id: Int) {
  // id is auto-parsed as Int; invalid values return a 400 error
  UserModel.find(id)
}

Multiple path parameters work as you would expect:

tova
route GET "/users/:user_id/posts/:post_id" => get_user_post

fn get_user_post(user_id: Int, post_id: Int) {
  PostModel.where({ user_id: user_id, id: post_id })
}

Route Guards and Decorators

The with keyword attaches middleware or guard functions to specific routes. Guards run before the handler and can reject requests early:

tova
route GET "/admin/users" with auth => get_users

Chain multiple guards with commas. They execute left to right:

tova
route DELETE "/users/:id" with auth, role("admin") => delete_user
route PUT "/settings" with auth, rate_limit(10) => update_settings

If any guard rejects the request, subsequent guards and the handler do not run.

Route Groups

Use routes to group routes under a shared path prefix. Groups can nest:

tova
routes "/api/v1" {
  route GET "/users" => get_users
  route POST "/users" => create_user

  routes "/admin" {
    route GET "/stats" with auth => get_stats
    route GET "/logs" with auth, role("admin") => get_logs
  }
}

In this example, the nested routes resolve to /api/v1/admin/stats and /api/v1/admin/logs.

Wildcard Routes

Routes support wildcard parameters for capturing the remainder of a path:

tova
route GET "/files/*path" => serve_file     // *param captures the rest of the URL
route GET "/proxy/*" => proxy_request      // trailing * is a catch-all

The wildcard value is passed to the handler as a parameter:

tova
fn serve_file(path: String) {
  // path contains everything after /files/
  // e.g., /files/images/logo.png -> path = "images/logo.png"
  read_file("./uploads/{path}")
}

Response Helpers

Tova provides built-in functions for constructing common HTTP responses.

JSON Responses

tova
respond(200, { data: users })           // JSON response with status 200
respond(201, user)                       // JSON response with status 201
respond(204, nil)                        // No content

Custom Headers

Pass a third argument to respond to include custom response headers:

tova
respond(200, data, { "X-Request-Id": req_id, "X-Total-Count": "42" })

Redirects

tova
redirect("/login")                      // 302 temporary redirect (default)
redirect("/login", 301)                 // 301 permanent redirect

HTML and Text

tova
html("<h1>Hello, world!</h1>")          // HTML response with Content-Type: text/html
text("plain text response")             // Text response with Content-Type: text/plain

Both html and text accept optional status code and header arguments.

Headers and Cookies

Setting Headers

Use with_headers to attach headers to a response:

tova
response = respond(200, data)
with_headers(response, {
  "X-Custom": "value",
  "Cache-Control": "no-cache"
})

Setting Cookies

set_cookie produces a cookie string. Attach it to the response via headers:

tova
cookie = set_cookie("session", token, {
  httpOnly: true,
  secure: true,
  maxAge: 86400
})
with_headers(response, { "Set-Cookie": cookie })

Common cookie options:

OptionDescription
httpOnlyCookie inaccessible to client-side JavaScript
secureCookie only sent over HTTPS
maxAgeLifetime in seconds
pathURL path scope
sameSite"Strict", "Lax", or "None"

Streaming

Use stream to send chunked responses progressively:

tova
route GET "/api/feed" => fn(req) {
  stream(fn(send, close) {
    send("chunk 1\n")
    send("chunk 2\n")
    send("chunk 3\n")
    close()
  })
}

The send callback writes data to the response stream immediately. Call close to end the stream.

Streaming with Delays

Stream data over time for real-time feeds or progress updates:

tova
route GET "/api/progress" => fn(req) {
  stream(fn(send, close) {
    for i in range(1, 11) {
      send("{i * 10}%\n")
      await sleep(500)
    }
    close()
  })
}

Generator Streaming

If a route handler uses yield, the response is automatically streamed as Server-Sent Events:

tova
route GET "/api/events" => fn(req) {
  yield { event: "start", data: "Processing..." }
  result = expensive_computation()
  yield { event: "complete", data: to_json(result) }
}

Each yielded value is sent as an SSE message. The stream closes when the generator finishes.

Streaming is useful for large responses, real-time data feeds, AI completions, or server-side rendering where you want to flush content incrementally.

Content Negotiation

The negotiate function inspects the request's Accept header and dispatches to the appropriate formatter:

tova
fn get_user(req, id: Int) {
  user = UserModel.find(id)
  negotiate(req, user, {
    html: fn(data) html("<h1>{data.name}</h1>"),
    xml: fn(data) text("<user><name>{data.name}</name></user>")
  })
}

If the client sends Accept: text/html, the html handler is called. If it sends Accept: application/xml, the xml handler runs. JSON is the default fallback.

Practical Tips

Keep handlers thin. Route handlers should validate input, call a service function, and return a response. Business logic belongs in separate functions.

Use route groups for API versioning. Wrapping routes in routes "/api/v2" { ... } makes it straightforward to maintain multiple API versions side by side.

Prefer typed path parameters. Adding type annotations like id: Int gives you automatic validation for free -- malformed parameters return a 400 error before your handler runs.

Use guards for cross-cutting concerns. Authentication, authorization, rate limiting, and input validation are all natural fits for the with decorator pattern.

Released under the MIT License.