http
HTTP client for making requests and a decorator-based HTTP server built on axum + tokio.
Client Functions
All client functions return a response object with the following fields:
| Field | Type | Description |
|---|---|---|
status | int | HTTP status code |
body | string | Raw response body |
json | any | Parsed JSON body (if applicable) |
headers | object | Response headers |
url | string | Final URL (after redirects) |
time | int | Response time in milliseconds |
method | string | HTTP method used |
http.get(url, options?) -> object
Sends an HTTP GET request.
let resp = http.get("https://api.example.com/users")
say resp.status // 200
say resp.json // [{id: 1, name: "Alice"}, ...]
http.post(url, options?) -> object
Sends an HTTP POST request.
let resp = http.post("https://api.example.com/users", {
body: { name: "Alice", email: "alice@example.com" }
})
say resp.status // 201
http.put(url, options?) -> object
Sends an HTTP PUT request.
let resp = http.put("https://api.example.com/users/1", {
body: { name: "Alice Updated" }
})
http.delete(url, options?) -> object
Sends an HTTP DELETE request.
let resp = http.delete("https://api.example.com/users/1")
say resp.status // 204
http.patch(url, options?) -> object
Sends an HTTP PATCH request.
let resp = http.patch("https://api.example.com/users/1", {
body: { active: false }
})
http.head(url, options?) -> object
Sends an HTTP HEAD request (headers only, no body).
let resp = http.head("https://example.com")
say resp.status
Options Object
| Field | Type | Description |
|---|---|---|
body | any | Request body (auto-serialized as JSON) |
headers | object | Custom request headers |
auth | string | Bearer token (sets Authorization: Bearer <token>) |
timeout | int or float | Time limit for the whole request, in seconds (default: 30; 0.5 is half a second) |
let resp = http.get("https://api.example.com/me", {
auth: "my-secret-token",
headers: { "Accept": "application/json" },
timeout: 10
})
Errors
A response with an error status (404, 500, …) is not an error: it is returned with ok == false. A request that gets no response raises a catchable error whose code says why:
| Code | e.type | Message (after http.get error: ) |
|---|---|---|
E0036 | TimeoutError | timed out after 10 seconds waiting for host:port |
E0037 | ConnectionError | connection refused by host:port, cannot resolve host 'name': ..., TLS handshake failed with host:port: ..., could not connect to host:port: ... |
Other failures (an invalid URL, a private address, a rejected redirect, a body over max_bytes) are E0016. fetch, http.download (default timeout 300 seconds) and http.crawl report the same codes. E0036 and E0037 messages name the host and port, not the full URL (a query string can carry credentials).
try {
http.get("https://api.example.com/slow", { timeout: 2 })
} catch e {
if e.code == "E0036" {
println("too slow, try again later")
} else if e.code == "E0037" {
println("server unreachable: {e.message}")
}
}
http.download(url, destination?) -> object
Downloads a file from url and saves it to destination. If no destination is provided, the filename is derived from the URL. Returns an object with path, size, and status.
let result = http.download("https://example.com/data.zip", "data.zip")
say result.size // bytes downloaded
http.crawl(url) -> object
Fetches a web page and extracts structured data. Returns an object with:
| Field | Type | Description |
|---|---|---|
url | string | The URL crawled |
status | int | HTTP status code |
title | string | Page title |
description | string | Meta description |
links | array | Array of absolute URLs found in href attributes |
text | string | Visible text content (first 500 characters) |
html_length | int | Total HTML length in characters |
let page = http.crawl("https://example.com")
say page.title
say len(page.links)
http.response(status, body?, headers?) -> object
Builds the response an HTTP handler returns when it needs a status code other than 200, response headers, or a body that is not JSON (see Server Decorators).
status— an Int from 200 to 599.body—null(the default) sends no body; a String is sent as is, withContent-Type: text/plain; charset=utf-8unlessheaderssets one; any other value is sent as JSON (application/jsonunless set).headers— an object of header names to values. A value may be a String, a number or a Bool, or an array of them (the header is sent once per element, e.g.Set-Cookie).
Invalid arguments raise an error when the response is built: a status outside 200-599, a body with status 204 or 304, an invalid header name, a header value containing a line break or other control character (so a value can never inject a header), and Content-Length, Transfer-Encoding or Connection, which the server sets.
@post("/items")
fn create(body) {
return http.response(201, { id: 7 }, { Location: "/items/7" })
}
@get("/health")
fn health() {
return http.response(200, "ok") // text/plain
}
@delete("/items/:id")
fn remove(id) {
return http.response(204) // no body
}
The result is an object ({ status, headers, body } tagged as an HttpResponse); a handler may adjust its fields before returning it. It is validated again when it is sent; an invalid one answers 500.
http.pretty(response) -> null
Pretty-prints an HTTP response object to stderr with color formatting.
let resp = http.get("https://api.example.com/status")
http.pretty(resp)
Server Decorators
Forge supports declarative HTTP servers using decorators. The @server decorator configures the server, and @get, @post, @put, @patch, @delete decorators define route handlers.
@server(port: 3000)
@get("/")
fn index() {
return { message: "Welcome to Forge!" }
}
@get("/users/:id")
fn get_user(id) {
if id == "0" {
return http.response(404, { error: "no such user" })
}
return { id: id, name: "User " + id }
}
@post("/users")
fn create_user(body, headers) {
if get(headers, "authorization") == null {
return http.response(401, { error: "unauthorized" })
}
return http.response(201, { ok: true, name: body.name }, { Location: "/users/1" })
}
@delete("/users/:id")
fn delete_user(id) {
return http.response(204)
}
Handler Parameters
Handler functions receive arguments based on parameter names:
- Path parameters (
:id,:name) are passed by matching the parameter name. bodyordatareceives the request body ({}when the request has none).raw_bodyreceives the body’s exact text, before decoding (""when the request has none) — for example to check a webhook’s signature.queryorqsreceives query string parameters as an object.headersreceives the request headers as an object with lower-case names (repeated headers joined with,).reqorrequestreceives the whole request:{ method, path, params, query, headers, body, raw_body }(bodyisnullandraw_bodyis""when there is none).- Any other name receives the query parameter of that name, or
null.
Path, query and header values are Strings. Use get(headers, "x-name") for an optional header: indexing a missing key is an error.
Request Bodies
The body is decoded by its Content-Type:
| Content type | body |
|---|---|
application/json, */*+json | the parsed JSON value |
application/x-www-form-urlencoded | an object of Strings |
| anything else, or none | the body as a String |
Invalid JSON is answered 400 and a body that is not UTF-8 text 415 (whatever its content type, so raw_body is always the exact text), with a JSON {"error": ...} body, before the handler runs. Bodies are limited to 2 MiB (413).
@post("/webhook")
fn webhook(raw_body, headers, body) {
let expected = crypto.sha256(env.get("WEBHOOK_SECRET", "") + raw_body)
if get(headers, "x-signature") != expected {
return http.response(401, { error: "bad signature" })
}
return { received: body.event }
}
Responses
A returned value is sent as 200 JSON. http.response(status, body?, headers?) sets the status, headers and body. A runtime error in a handler answers 500 {"error": message}.
Databases
A database the program’s top level opens (db.open, pg.connect) is shared by all handlers. Statements of concurrent requests run one at a time on the connection; a transaction (db.begin() … db.commit(), or BEGIN in SQL) belongs to the request that opened it — other requests wait for it — and is rolled back if the request ends without committing. db.last_insert_rowid() returns the request’s own last insert. db.open or db.close inside a handler affects that request only.
Server Features
- Built on axum and tokio; each request runs in an isolated fork of the program, concurrently with other requests.
- CORS is same-origin by default;
@server(cors: "permissive")allows any origin. - WebSocket support via the
@wsdecorator.