Routes & Params
A route binds an HTTP method and path to a handler body. Path segments prefixed with : are typed parameters; a trailing *name is a wildcard capturing the rest of the path.
route := 'route' METHOD STRING params? ('->' Type)? '{' stmt* '}'
params := '(' param (',' param)* ')'
param := 'query'? NAME ':' Type ('=' Expr)? # path (default) or query sourceTyped path parameters
(id: int) binds and parses the :id path segment; a value that fails to parse becomes a 400. Wildcards bind a string.
route GET "/users/:id" (id: int) {
respond "user id = {id}\n"
}
route GET "/files/*path" {
respond "serving file: {path}\n"
}Query parameters
Prefix a param with query to read it from the query string. Give it a default with =, or make it optional with T? and coalesce with ??:
route GET "/users/:id/posts/:post" (id: int, post: int, query q: string?) {
respond "user {id}, post {post}, filter={q ?? "none"}\n"
}
route GET "/greet/:name" (name: string, query loud: bool = false) {
let greeting = "Hello, " + name
respond "{greeting} (loud={loud})\n"
}Statements in a handler
Inside a route body you can set the status, the content type, and respond:
route GET "/" {
type "text/html" # set Content-Type
respond "<h1>Weld API</h1>\n"
}
route GET "/health" {
status 200 # set status code
respond "OK\n"
}
route GET "/teapot" {
status 418
respond "I'm a little teapot\n"
}respond Expr— send the value (a record/list becomes JSON, a string is sent as text).status Expr— set the HTTP status code.type STRING— set theContent-Typeheader.emit Expr— stream a chunk (see below).
Response headers & cookies
Inside a route handler (or a decorator) you can set arbitrary response headers and cookies — useful for CORS, caching, and cookie sessions. These are not available in a hub or alongside emit streaming.
header name value— set an arbitrary response header, e.g. a CORS or caching header, or a rawSet-Cookie.cookie name value— set aSet-Cookiewith secure session defaults (Path=/; HttpOnly; SameSite=Lax). For full control (Max-Age,Secure) write the header yourself withheader "Set-Cookie" "…".request_cookie(name) -> string?— read a cookie off the request (companion torequest_header(name)).
route GET "/api/data" -> string {
header "Access-Control-Allow-Origin" "*"
header "Cache-Control" "max-age=60"
respond "some data"
}
route GET "/login/:user" (user: string) -> string {
cookie "session" "token-for-{user}"
respond "logged in as {user}"
}
route GET "/me" -> string {
let token = request_cookie("session") else { fail 401 "not logged in" }
respond "session={token}"
}Form bodies & file uploads
A handler can read HTML form fields and multipart file uploads off the request body. Each returns an optional (null when absent), so pair it with ?? or an else:
form_field(name) -> string?— a field from a urlencoded or multipart/form-data body (the encoding is auto-detected).upload(name) -> string?— a multipart file part's raw bytes (binary-safe).upload_filename(name) -> string?— an uploaded file's filename.
route POST "/contact" -> string {
let name = form_field("name") ?? "anonymous"
let message = form_field("message") else { fail 400 "message is required" }
respond "thanks {name}, we received {len(message)} chars"
}
route POST "/upload" -> string {
let file = upload("file") else { fail 400 "no file part named 'file'" }
let filename = upload_filename("file") ?? "upload.bin"
respond "stored {filename} ({len(file)} bytes)"
}For inputs too large to buffer, stream the body with for chunk in request_body() — each chunk is a slice of raw bytes, read in constant memory (the whole body is never held at once). Such a route can't also declare a JSON body parameter.
route POST "/measure" -> string {
var bytes = 0
for chunk in request_body() {
bytes = bytes + len(chunk)
}
respond "received {bytes} bytes"
}To stream typed records instead of raw bytes, declare the body as Stream<T> and for over it — each record is JSON-decoded one at a time off the socket (constant memory, no MAX_BODY_SIZE limit), accepting a JSON array or NDJSON. body rows: [T] buffers the whole list; body rows: Stream<T> streams it — the loop body is the same. See Bulk import.
Interpolation
{expr} inside a string evaluates a type-checked expression ("sum={a + b}", "hi {name}"); a doubled brace (two { or two }) is a literal brace. Strings support the escapes \n \t \r \" \\ \0 and \u{HEX} (a Unicode codepoint, e.g. "\u{2603}" → ☃), and # starts a line comment.
route GET "/add/:a/:b" (a: int, b: int) {
respond "sum={a + b}, quotient={a / b}\n"
}Streaming responses
Use emit instead of respond to stream a response chunk by chunk (HTTP chunked transfer encoding) — the full body is never held in memory; each chunk is flushed as it is produced:
route GET "/stream" {
type "text/plain"
for x in [10, 20, 30] { emit "value = {x}\n" }
}type, header, and cookie set before the first emit are written into the chunked reply's head, so you can stream a proper download. Read a file chunk-by-chunk with read_file_stream to serve arbitrarily large files in constant memory:
route GET "/download/:name" (name: string) -> string {
type "application/octet-stream"
header "content-disposition" "attachment; filename=\"{name}\""
for chunk in read_file_stream("assets/{name}") { emit chunk } # nothing emitted if it can't open
}A respond before the first emit is a terminal early bail-out — it sends a normal buffered reply and returns, so you can validate first (if bad { status 400 respond "…" }) and stream only on the happy path.
Static files
serve BASE SUB sends the file at BASE/SUB — content type inferred from the extension, 404 if missing, 403 on a .. traversal attempt. Pair it with a wildcard route so the remaining path is the subpath:
route GET "/assets/*path" (path: string) {
serve "public" path # serves ./public/<path>
}