std/web is a small REST framework over HTTP/1.1. You write controllers —
classes that implement the WebRequestHandler interface — and route by
overloading its handle method with where guards. Payloads are
(de)serialized automatically — there is no manual JSON.
import "std/web.xi"
A controller is any class that implements WebRequestHandler. The contract is one
action method — action is an impure function kind: it may mutate, and is not
a pure function. Route by writing several handle overloads, each guarded with
where; the first matching overload wins.
Controllers are auto-registered — every class implementing
WebRequestHandler is discovered and DI-wired automatically, with no bind.
Split routes across as many controllers as you like; the server tries each (in
declaration order) and the first overload whose guard matches handles the
request. An unmatched request falls through to a 404.
event Health { ok: Bool }
event User { name: String, active: Bool }
class HealthController implements WebRequestHandler {
action handle(req: HttpRequest, res: HttpResponse) where req.path == "/health" {
res.send(Health { ok: true })
}
}
class UserController implements WebRequestHandler {
deps { repo: Repo } // injected, as usual
action handle(req: HttpRequest, res: HttpResponse) where req.path == "/user" {
let name = req.query("name")
res.send(User { name: name, active: repo.active(name) })
}
}
module App {} // no bind needed
A controller whose guards don't match simply leaves the response untouched, so
the next controller gets a turn. (An un-guarded handle overload always matches,
so use one only as a deliberate catch-all, and declare that controller last.)
WebRequestHandler also has a getBaseUrl() method with a default
implementation returning "/"; the server only consults a controller when the
request path starts with it. Override it to mount a controller under a prefix:
class ApiController implements WebRequestHandler {
mapper getBaseUrl() -> String { return "/api/v1" } // only sees /api/v1/*
action handle(req: HttpRequest, res: HttpResponse) where req.path == "/api/v1/stats" {
res.send(stats())
}
}
(where guards still compare the full req.path.) getBaseUrl is an ordinary
interface default method — any interface method may carry a { … } body that
implementors inherit unless they override it.
| Accessor | Returns |
|---|---|
req.path |
the request path |
req.method |
the HTTP method ("GET", …) |
req.body |
the raw body as a String |
req.query("q") |
a ?q=… query value ("" if absent) |
req.header("X") |
a request header (case-insensitive; "" if absent) |
req.parse(T) |
the body deserialized into a T via the WebTransport |
For routes with path parameters, route on method + a /users/:id pattern and
pull each source out as its own typed value. web.route captures the :params;
the four source getters each return a flat Json you decode with as T:
| Call | Returns |
|---|---|
web.route(req, "POST", "/users/:id") |
Bool — method+pattern match (use as a where guard; captures :params) |
web.params(req) |
path params as Json |
web.query(req) |
query string as Json |
web.headers(req) |
headers as Json (names normalized: Content-Type → content_type) |
web.body(req) |
the parsed body as Json |
json as T decodes a Json into any compound type, coercing string scalars —
so a "42" path segment becomes an Integer and "true" a Bool:
type IdRef = { id: Integer }
type NewUser = { name: String, email: String }
type Auth = { authorization: String }
action handle(req, res) where web.route(req, "POST", "/users/:id") {
let ref = web.params(req) as IdRef // path -> typed
let body = web.body(req) as NewUser // body -> typed
let auth = web.headers(req) as Auth // header -> typed
res.send(create(ref.id, body.name, auth.authorization))
}
Each source is decoded independently into the shape you want — no envelope, no
merge order. as T is a general decode (it also works on any Json, e.g. from
json.parse), so nothing about HTTP leaks into the language. See
examples/web_params_demo.xi.
capture pairs nicely here: name a value computed inside the guard (e.g. an
order total) and reuse it in the body and the response —
if lineTotal(order) capture total: Integer > 0 { … res.send(Receipt { total: total }) }.
See capture and
examples/web_capture_demo.xi.
The response is mutable — fill it in, don't return it.
| Method | Effect |
|---|---|
res.send(dto) |
serialize dto via the WebTransport and reply 200 |
res.sendStatus(code, msg) |
reply code with a plain-text msg |
res.sendText(code, body) |
reply code with a plain-text body |
res.send / req.parse work for any event or compound type; the compiler
derives the codec automatically. A request that no controller matches gets a
404.
res.send(dto) and req.parse(T) route through the bound WebTransport, which
maps a value to/from the wire body. The default is JSON:
interface WebTransport {
mapper serialize(payload: Json) -> String
mapper deserialize(body: String) -> Json
}
Bind your own implementor (e.g. a different wire format) to replace it:
module App {
bind WebTransport -> MsgPackTransport // overrides the JSON default
}
async entry main(args: String[]) -> Integer {
web.serve(8080) // blocking HTTP/1.1 server
return 0
}
$ xc app.xi && ./build/app
web: serving on http://0.0.0.0:8080web.serveTLS(port, certPath, keyPath) runs the same handler stack over TLS.
TLS is opt-in so default builds stay dependency-light: compile the program
with XC_TLS=1, which links the platform's OpenSSL.
async entry main(args: String[]) -> Integer {
web.serveTLS(8443, "cert.pem", "key.pem") // PEM cert + private key
return 0
}
$ XC_TLS=1 xc app.xi && ./build/app
web: serving on https://0.0.0.0:8443Built without XC_TLS, serveTLS prints a notice and serves nothing (so the
program still compiles everywhere). Everything else — controllers, res.send,
WebTransport — is identical to plaintext.
web.serveHttp2(port, cert, key) runs the same handlers over HTTP/2 (ALPN h2,
falling back to HTTP/1.1 for clients that don't negotiate it). Build with
XC_HTTP2=1, which links OpenSSL and nghttp2:
$ XC_HTTP2=1 xc app.xi && ./build/app
web: serving HTTP/2 on https://0.0.0.0:8443
$ curl --http2 -k https://localhost:8443/health # negotiates h2 via ALPNHTTP/3 (QUIC) is not yet available — it needs a QUIC stack (ngtcp2/quiche),
which isn't broadly installed and has no common client to test against; it's the
one open transport.
- HTTP/1.1, blocking, one request per connection (no keep-alive) — fine for an API behind a reverse proxy; concurrency and keep-alive are future work.
- Routing is by exact
req.pathmatch inwhereguards. There is no path-pattern capture (/users/:id); read sub-paths fromreq.pathor use query parameters. - HTTPS / HTTP/2 need OpenSSL (and nghttp2) at build time (
XC_TLS=1/XC_HTTP2=1). HTTP/3 is the one remaining transport (QUIC). - No
%-decoding of query values yet; no multipart parsing.
See examples/web_demo.xi.