Building HTTP services
Glide ships an HTTP/1.1 server in stdlib::http, built on the async stdlib::net from the concurrency chapter. Every read and write parks the coroutine instead of blocking a thread, so a single process holds tens of thousands of live connections. This chapter builds up from the smallest possible server to a complete JSON API, layer by layer.
Hello, server
The smallest server is a handler function plus http_listen:
import stdlib::http::*;
fn root(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().body("hello from ".concat(req.path));
}
fn main() -> !i32 {
http_listen(8080, root)?;
return ok(0);
}
A handler is fn(*HttpRequest) -> HttpResponse — it takes the request by pointer and returns a response by value. http_listen(port, handler) binds the port and serves forever; it returns !i32, so propagate the bind error with ? (note main returns !i32 to allow that).
Run it, then in another terminal:
curl http://127.0.0.1:8080/anything
# → hello from /anything
That is the whole shape of a Glide server. The rest of this chapter expands each piece: the entry points that bind the port, the request and response types, and the routing, middleware, and extractor layers that sit on top.
Serving: http_listen, HTTPS, and workers
Every Glide HTTP server boils down to two things: a handler — a plain function that turns a request into a response — and a listen call that binds a port and feeds requests to that handler. There is no framework object to construct at this layer; routing, middleware, and extractors all live on top of these entry points. Get the entry points right and everything else slots in.
The handler
A handler is just a function with this exact shape:
fn(*HttpRequest) -> HttpResponse
It takes a pointer to the fully-buffered request and returns a response by value. The request lifetime is tied to the call — HttpRequest's own doc comment warns "do not stash the pointer." You pass the handler by name — root, not root(). It is a function value matching fn(*HttpRequest) -> HttpResponse.
http_listen — the plain entry point
pub fn http_listen(port: i32, handler: fn(*HttpRequest) -> HttpResponse) -> !i32
http_listen binds port, then loops accepting connections forever. It returns !i32, so propagate the bind failure with ? (it returns err("bind failed") if the port can't be bound). In practice the ok(0) at the end is never reached — the accept loop runs until the process is killed — but the signature lets you fail fast on a bad bind.
The concurrency model is coroutine-per-connection (M:N): when the platform has an async reactor wired up (epoll on Linux, kqueue on macOS/BSD), each accepted connection is handled by a spawned coroutine. Every socket read and write parks that coroutine instead of pinning an OS thread, so a single process can hold tens of thousands of concurrent connections cheaply. On platforms without a reactor, http_listen falls back to handling each connection serially on the calling thread — correct, just not concurrent.
https_listen — TLS with ALPN
pub fn https_listen(port: i32, cert_path: string, key_path: string,
handler: fn(*HttpRequest) -> HttpResponse) -> !i32
Same handler shape, plus the paths to your PEM certificate and private key. The cert and key are passed as filesystem paths (strings), not as in-memory blobs — the server reads them when it binds.
Under the hood https_listen advertises both HTTP/2 and HTTP/1.1 via ALPN ("h2,http/1.1"). Clients that speak h2 negotiate the binary protocol; HTTP/1.1-only clients fall back transparently. Your handler doesn't change either way. Requests that arrived over TLS have req.tls == true, which reverse-proxy middleware uses to stamp the correct X-Forwarded-Proto.
import stdlib::http::*;
fn root(req: *HttpRequest) -> HttpResponse {
if req.path.eq("/health") {
return HttpResponse::ok().body("ok");
}
return HttpResponse::ok()
.set("Content-Type", "text/plain")
.body("secure: ".concat(req.path));
}
fn main() -> !i32 {
https_listen(8443, "certs/server.crt", "certs/server.key", root)?;
return ok(0);
}
Worker variants — when one accept loop isn't enough
http_listen runs a single accept loop. On a multi-core box you can spread accepts across several worker threads. There are two worker entry points, and the difference between them is the single most important choice you make here.
pub fn http_listen_workers(port: i32, n: i32,
handler: fn(*HttpRequest) -> HttpResponse) -> !i32
pub fn http_listen_workers_blocking(port: i32, n: i32,
handler: fn(*HttpRequest) -> HttpResponse) -> !i32
| Function | When to use | Handler restrictions | Throughput (reference 4-core box) |
|---|---|---|---|
http_listen_workers |
Hot, stateless endpoints (health checks, cache hits, pure-CPU JSON) | Fast path is non-parking — keep the handler from blocking | ~159k req/s |
http_listen_workers_blocking |
Handlers that genuinely block: DB queries, downstream HTTP, chan sync |
None — blocking parks the connection's coro, not the thread | ~107k req/s |
http_listen_workers tries a fast state-machine dispatch path first (Linux); if the OS doesn't support it (-1 return), it transparently falls back to http_listen_workers_blocking. So when in doubt about whether you block, use the blocking variant — it's slower but unrestricted, and it's the safe default for any handler that touches a database or another service.
A stateless worker pool:
import stdlib::http::*;
fn handler(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().body("served path: ".concat(req.path));
}
fn main() -> !i32 {
// N worker threads; the kernel load-balances accepts across them.
http_listen_workers(8080, 4, handler)?;
return ok(0);
}
And the blocking-capable pool, for handlers that do real I/O:
import stdlib::http::*;
fn handler(req: *HttpRequest) -> HttpResponse {
// May block: DB queries, downstream HTTP, chan recv.
// Each connection parks its own coroutine, not the worker thread.
return HttpResponse::ok().body("blocking-capable: ".concat(req.method));
}
fn main() -> !i32 {
http_listen_workers_blocking(8080, 4, handler)?;
return ok(0);
}
Choosing an entry point
- One core, simple service, or just getting started → `http_listen`.
- TLS / HTTPS (and free HTTP/2) → `https_listen`.
- Multi-core, handlers that touch a DB or call other services → `http_listen_workers_blocking`.
- Multi-core, hot stateless endpoints where you've confirmed the handler never parks → `http_listen_workers`.
All four share the same handler signature, so you can swap entry points without touching your handler. And all four return !i32 — propagate the bind error with ? and you'll get a clean failure if the port is taken.
Requests and responses
Every entry point above hands your handler the same two types. HttpRequest is the fully-buffered, read-only view of what came off the wire; HttpResponse is a small immutable value you assemble with a builder chain. Both live in stdlib::http.
HttpRequest: the fields
The request is a plain struct. Its fields are all pub, so you can read them directly — there's no getter ceremony for the request line:
pub struct HttpRequest {
pub method: string, // "GET", "POST", ...
pub path: string, // "/users/42?fields=name" (raw, with query)
pub version: string, // "HTTP/1.1"
pub headers_block: string, // raw "Name: Value\r\n..." text
pub body: string, // fully buffered request body
pub params: *HashMap<string>, // router path params (may be null)
pub queries: *HashMap<string>, // parsed query map (lazy; may be null)
pub tls: bool, // true when served via https_listen
}
A few things worth internalising:
- `path` is the raw target, query string included. Use
path_only()when you want just the route portion, andquery(name)to pull a decoded query value — don't hand-parsepath. - `headers_block` is the raw wire text. You can read it, but reach for
header(name)instead; it does a case-insensitive lookup and caches the parse. - `tls` is how middleware knows whether the request arrived encrypted (it drives, e.g., the
X-Forwarded-Protostamp). It'strueonly underhttps_listen.
HttpRequest: the methods
The impl gives you ergonomic accessors. All of them take self: *HttpRequest and never allocate a surprise — the header and query maps are built once and cached.
| Method | Returns | What it does |
|---|---|---|
header(name) |
string |
Case-insensitive header lookup; "" if absent. First call parses headers_block into a cache; repeats are O(1). |
param(name) |
string |
Router path parameter (e.g. :id); "" if no router matched or the name is unknown. |
query(name) |
string |
Decoded query-string value; "" if missing. Parses ?k=v&… from path on first use, then caches. |
path_only() |
string |
path with the ?… query segment stripped. |
is_method(m) |
bool |
Case-insensitive method check, e.g. req.is_method("GET"). |
body_json() |
!*JsonValue |
Parse the body as JSON; err on empty or malformed input. |
Reading from a request looks like this — every accessor returns "" for the missing case, so there's no ?T to unwrap on the lookup side:
import stdlib::http::*;
fn handler(req: *HttpRequest) -> HttpResponse {
let host: string = req.header("Host"); // "" if no Host header
let id: string = req.param("id"); // "" without a router match
let page: string = req.query("page"); // "" if no ?page=
let route: string = req.path_only(); // "/users/42" from "/users/42?x=1"
if req.is_method("GET") {
return HttpResponse::ok().text(host.concat(id).concat(page).concat(route));
}
return HttpResponse::ok().text("hi");
}
Reading a JSON body
body_json() returns !*JsonValue — a Result, because the body might be empty or not valid JSON. The signature is:
pub fn body_json(self: *HttpRequest) -> !*JsonValue
Check .ok (or propagate with ?) before touching the value. JsonValue's typed getters like get_string themselves return a Result, so a full bind is a couple of guarded steps:
import stdlib::http::*;
import stdlib::json::*;
fn create(req: *HttpRequest) -> HttpResponse {
let v: !*JsonValue = req.body_json();
if !v.ok {
return HttpResponse::with_status(400).text(v.err); // empty / invalid JSON
}
let name: !string = v.val.get_string("name");
if !name.ok {
return HttpResponse::with_status(422).text("missing name");
}
return HttpResponse::ok().json_of(v.val).status(201);
}
HttpResponse: the builder model
HttpResponse is built by value. Every builder method takes self by value and returns a brand-new HttpResponse:
pub fn body(self: HttpResponse, s: string) -> HttpResponse
That's why you chain them: HttpResponse::ok().text("hi").status(201). There is no mutation in place — each call hands you the next response in the chain. The struct itself is small:
pub struct HttpResponse {
pub status: i32,
pub headers_block: string,
pub body: string,
pub stream_src: *dyn ChunkSource, // null for buffered responses
pub file_path: string, // non-empty for the sendfile path
}
Constructors
You start a response with one of five static constructors:
| Constructor | Status | Notes |
|---|---|---|
HttpResponse::ok() |
200 | Empty body; chain .body/.text/… to fill it. |
HttpResponse::not_found() |
404 | Shortcut for with_status(404). |
HttpResponse::with_status(code) |
code |
The general case: any status, no body, no headers. |
HttpResponse::redirect(url) |
302 | Sets Location: url. Chain .status(301) for a permanent redirect. |
HttpResponse::file(path, mime) |
200 | Zero-copy sendfile from disk; mime becomes Content-Type. |
HttpResponse::ok();
HttpResponse::not_found();
HttpResponse::with_status(204);
HttpResponse::redirect("/login").status(301);
HttpResponse::file("./static/index.html", "text/html; charset=utf-8");
file(...) is special: the server takes the body's length from the file's size on disk and streams the bytes from the kernel page cache straight to the socket — the body field is ignored on that path. (More on serving files later, under static files.)
Builder methods
Once you have a response, these methods shape it. The ones that set a body also stamp the matching Content-Type for you:
| Method | Sets Content-Type? |
What it does |
|---|---|---|
body(s) |
no | Sets the body (and Content-Length). No content type — raw bytes. |
text(s) |
text/plain; charset=utf-8 |
Body + plain-text content type. |
html(s) |
text/html; charset=utf-8 |
Body + HTML content type. |
json(s) |
application/json |
Body from a JSON string. |
json_of(v) |
application/json |
Body from a *JsonValue, serialised via v.emit(). |
set(name, value) |
— | Set a header, replacing any existing same-name line. |
add(name, value) |
— | Append a header line; keeps existing same-name lines. |
status(code) |
— | Override the status code. |
cookie(name, value) |
— | Append a Set-Cookie: name=value (routes through add). |
header(name) |
— | Reads a header back out, case-insensitive; returns string. |
headers(name) |
— | Reads every matching header line; returns *Vector<string>. |
stream(source) |
— | Stream the body chunk-by-chunk from a ChunkSource. |
So text, html, json, and json_of are the four that set Content-Type; body deliberately does not (you bring your own type, or none). Note that set replaces by name while add appends — use set for Content-Type, add (or cookie) for Set-Cookie and Vary, which legitimately repeat.
import stdlib::http::*;
import stdlib::json::*;
fn handler(req: *HttpRequest) -> HttpResponse {
let r: HttpResponse = HttpResponse::ok()
.body("raw bytes") // sets Content-Length, no Content-Type
.text("plain") // Content-Type: text/plain
.html("<h1>hi</h1>") // Content-Type: text/html
.json("{\"ok\":true}") // Content-Type: application/json
.set("X-One", "1") // replace-by-name header
.add("Vary", "Accept") // append header (repeats allowed)
.add("Vary", "Origin")
.status(200)
.cookie("session", "abc123"); // appends a Set-Cookie line
// json_of takes a *JsonValue and serialises it for you.
let v: *JsonValue = JsonValue::object();
v.obj_set("ok", JsonValue::bool(true));
return HttpResponse::ok().json_of(v);
}
fn main() -> i32 {
http_listen(8080, handler);
return 0;
}
Reading a response back
Why read your own response? Middleware and tests need it — to assert a Content-Type, or to pull every Set-Cookie line you appended:
import stdlib::http::*;
fn main() -> i32 {
let resp: HttpResponse = HttpResponse::ok()
.cookie("session", "abc")
.cookie("csrf", "xyz")
.set("Content-Type", "application/json")
.body("{}");
let ct: string = resp.header("content-type"); // case-insensitive
println!("ct =", ct);
let cookies: *Vector<string> = resp.headers("Set-Cookie");
for let i: i32 = 0; i < cookies.len(); i++ {
println!("cookie:", cookies.get(i));
}
return 0;
}
header(name) gives you the first match (or ""); headers(name) gives you every matching line — which is exactly what you want for Set-Cookie. The stream(source) builder for chunked bodies is covered in detail under streaming and SSE later in the chapter.
Routing with Router
The bare http_listen loop hands every request to a single handler. Real apps need to fan out by method and path: GET /users/:id to one function, POST /users to another, everything else to a 404. That is what Router gives you — a method-aware dispatch table you build up front and then hand to a listener.
Router lives in its own module, so you need both the core HTTP types and the router import:
import stdlib::http::*;
import stdlib::http::router::*;
Building a router and listening
A handler is a plain function pointer of type fn(*HttpRequest) -> HttpResponse. You create an empty router with Router::new() (it returns a *Router with a default 404 handler), register routes, then call listen:
import stdlib::http::*;
import stdlib::http::router::*;
fn root(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().text("hi");
}
fn get_user(req: *HttpRequest) -> HttpResponse {
let id: string = req.param("id");
return HttpResponse::ok().json("{\"id\":\"".concat(id).concat("\"}"));
}
fn create_user(req: *HttpRequest) -> HttpResponse {
return HttpResponse::with_status(201).body(req.body);
}
fn main() -> i32 {
let r: *Router = Router::new();
defer r.free();
r.get("/", root);
r.get("/users/:id", get_user);
r.post("/users", create_user);
let served: !i32 = r.listen(8080);
return served.val;
}
new() heap-allocates the router, so pair it with defer r.free() to release the route table at scope end.
listen has the signature:
pub fn listen(self: *Router, port: i32) -> !i32
It returns a !i32 because binding the port can fail (err("bind failed")); on success it blocks forever serving requests, so in practice the Ok value is only reached when the loop tears down. Bind the result (or propagate it with ?) so the error is not silently discarded.
Pattern syntax
Every pattern is split on / into segments. Each segment is one of three shapes:
| Segment | Kind | Matches | Capture |
|---|---|---|---|
/users |
literal | exactly that text, byte-for-byte | none |
/users/:id |
parameter | any one path segment | req.param("id") |
/static/*rest |
wildcard | the rest of the path (incl. empty) | req.param("rest") |
A parameter segment starts with : and captures a single segment. A wildcard starts with *, must be the last segment, and captures everything remaining joined by / (it even matches an empty tail, so /static/*rest matches a bare /static). You read both back through req.param(name), which returns "" for an unknown name:
// for route "/users/:id" matched against /users/42
req.param("id"); // "42"
req.param("zzz"); // ""
The verb shortcuts
route(method, pattern, handler) is the primitive — the method is matched case-insensitively and stored upper-case. The verb methods are thin wrappers around it, and any registers a catch-all that matches every verb (it stores the sentinel ANY_METHOD, i.e. "*"):
import stdlib::http::*;
import stdlib::http::router::*;
fn show(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().text("show ".concat(req.param("id")));
}
fn serve_asset(req: *HttpRequest) -> HttpResponse {
let rest: string = req.param("rest");
return HttpResponse::ok().text("asset: ".concat(rest));
}
fn health(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().text("ok");
}
fn main() -> i32 {
let r: *Router = Router::new();
defer r.free();
r.get("/items/:id", show);
r.put("/items/:id", show);
r.patch("/items/:id", show);
r.delete("/items/:id", show);
r.head("/items/:id", show);
r.options("/items/:id", show);
r.any("/health", health); // every method
r.get("/static/*rest", serve_asset); // wildcard tail
// Lower-level forms.
r.route("GET", "/legacy", health);
r.route(ANY_METHOD, "/ping", health);
let served: !i32 = r.listen(8080);
return served.val;
}
| Method | Equivalent to |
|---|---|
get(p, h) |
route("GET", p, h) |
post(p, h) |
route("POST", p, h) |
put(p, h) |
route("PUT", p, h) |
delete(p, h) |
route("DELETE", p, h) |
patch(p, h) |
route("PATCH", p, h) |
head(p, h) |
route("HEAD", p, h) |
options(p, h) |
route("OPTIONS", p, h) |
any(p, h) |
route(ANY_METHOD, p, h) — matches every verb |
Dispatching directly
listen calls dispatch for each parsed request, but you can call it yourself — it is the seam to unit-test routing without opening a socket:
pub fn dispatch(self: *Router, req: *HttpRequest) -> HttpResponse
dispatch walks the route table, populates req.params on the first match, runs the middleware chain, then the handler — falling back to the not-found handler when nothing matches. It mutates req.params in place, so pass a freshly-built request:
import stdlib::http::*;
import stdlib::http::router::*;
import stdlib::hashmap::*;
fn get_user(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().text("user ".concat(req.param("id")));
}
fn main() -> i32 {
let r: *Router = Router::new();
defer r.free();
r.get("/users/:id", get_user);
let req: HttpRequest = HttpRequest {
method: "GET",
path: "/users/42",
version: "HTTP/1.1",
headers_block: "",
body: "",
params: null,
queries: null,
tls: false,
headers_cache: null,
};
let resp: HttpResponse = r.dispatch(&req);
println!("status:", resp.status); // 200
println!("body:", resp.body); // user 42
return 0;
}
Custom 404, sub-routers, and state
Three more router methods round out a real app: not_found_with overrides the default 404 handler, scope(prefix, sub) mounts a sub-router's routes under a prefix, and state(p) stashes an application-state pointer that handlers reach later through the State<T> extractor.
import stdlib::http::*;
import stdlib::http::router::*;
struct Config {
name: string,
}
fn get_user(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().text("user ".concat(req.param("id")));
}
fn list_users(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().text("all users");
}
fn missing(req: *HttpRequest) -> HttpResponse {
return HttpResponse::with_status(404).json("{\"err\":\"missing\"}");
}
fn main() -> i32 {
// A sub-router holds its own routes; scope mounts them under a prefix.
let api: *Router = Router::new();
api.get("/users", list_users);
api.get("/users/:id", get_user);
let r: *Router = Router::new();
defer r.free();
let cfg: *Config = new Config { name: "prod" };
r.state(cfg as *void); // reachable via State<Config>
r.scope("/api/v1", api); // mounts /api/v1/users and /api/v1/users/:id
r.not_found_with(missing); // custom 404
let served: !i32 = r.listen_workers(8080, 4);
return served.val;
}
scope copies the sub-router's route entries (with the prefix prepended), so the sub-router can be dropped or reused afterwards. Any middleware registered on the sub-router becomes per-route middleware that runs after the parent's chain and before the handler — middleware accumulates naturally through nested scopes.
Per-route middleware with route_with
The router-level use_mw(mw) chain runs before every handler. When you want middleware scoped to a single route instead, use route_with, which takes a vector of middleware to run between the global chain and the handler:
pub fn route_with(self: *Router, method: string, pattern: string,
mws: *Vector<fn(*HttpRequest, *Chain) -> HttpResponse>,
handler: fn(*HttpRequest) -> HttpResponse)
Each middleware has the shape fn(*HttpRequest, *Chain) -> HttpResponse. It either calls chain_next(c) to advance to the next middleware (and eventually the handler), or returns early to short-circuit the chain:
import stdlib::http::*;
import stdlib::http::router::*;
fn auth(req: *HttpRequest, c: *Chain) -> HttpResponse {
if req.header("Authorization").len() == 0 {
return HttpResponse::with_status(401).text("no auth");
}
return chain_next(c);
}
fn secret(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().text("top secret");
}
fn main() -> i32 {
let r: *Router = Router::new();
defer r.free();
let mws: *Vector<fn(*HttpRequest, *Chain) -> HttpResponse> = Vector::new();
mws.push(auth);
r.route_with("GET", "/secret", mws, secret);
let served: !i32 = r.listen(8080);
return served.val;
}
Returning a response without calling chain_next skips the rest of the middleware and the route handler — the standard pattern for an auth gate that bails out with a 401 before the handler ever runs. The next section explores that pattern in full.
Middleware
Middleware lets you wrap every request in shared behaviour — logging, auth checks, CORS headers, metrics — without touching each handler. In Glide, a middleware is just a function with a fixed shape, and the Router threads a Chain through them so each one decides whether to continue or stop.
The middleware signature
Every middleware is a function of this exact type:
fn(*HttpRequest, *Chain) -> HttpResponse
It receives the request and a *Chain. To pass control to the next middleware (and eventually the route handler), it calls chain_next(c). To stop the request cold — say, reject an unauthenticated caller — it simply returns an HttpResponse without calling chain_next. That short-circuits the rest of the chain and the route handler.
The Chain is the per-dispatch context the router builds and walks for you:
pub struct Chain {
pub idx: i32,
pub mws: *Vector<fn(*HttpRequest, *Chain) -> HttpResponse>,
pub handler: fn(*HttpRequest) -> HttpResponse,
pub req: *HttpRequest,
}
pub fn chain_next(c: *Chain) -> HttpResponse { /* ... */ }
chain_next advances the index: from inside middleware N it runs middleware N+1, and once the list is exhausted it invokes the route handler. You never construct a Chain yourself — you receive it and pass it along.
Registering global middleware with use_mw
Router::use_mw adds a middleware that runs before every matched handler, in registration order:
pub fn use_mw(self: *Router,
mw: fn(*HttpRequest, *Chain) -> HttpResponse) -> *Router
Here is a logging middleware that records the method, path, and final status:
import stdlib::http::*;
import stdlib::http::router::*;
fn index(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().text("hello");
}
fn logger(req: *HttpRequest, c: *Chain) -> HttpResponse {
println!("[", req.method, "] ", req.path);
let resp: HttpResponse = chain_next(c); // run the rest of the chain
println!(" -> ", resp.status); // inspect the result
return resp;
}
fn main() -> i32 {
let r: *Router = Router::new();
defer r.free();
r.use_mw(logger);
r.get("/", index);
return 0;
}
use_mw returns self, so you can chain registrations if you prefer.
Short-circuiting: an auth gate
To reject a request, return early without calling chain_next. Everything after this middleware — including the handler — is skipped:
import stdlib::http::*;
import stdlib::http::router::*;
fn whoami(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().text("you are in");
}
fn auth_gate(req: *HttpRequest, c: *Chain) -> HttpResponse {
let token: string = req.header("Authorization");
if token.len() == 0 {
// No chain_next -> the handler never runs.
return HttpResponse::with_status(401).json("{\"error\":\"unauthorized\"}");
}
return chain_next(c);
}
fn main() -> i32 {
let r: *Router = Router::new();
defer r.free();
r.use_mw(auth_gate);
r.get("/me", whoami);
return 0;
}
Ordering
Middleware runs in the order you register it. The chain nests like layers of an onion: each middleware's code before chain_next runs on the way in, and its code after chain_next runs on the way out (in reverse).
fn first(req: *HttpRequest, c: *Chain) -> HttpResponse {
println!("first: before");
let resp: HttpResponse = chain_next(c);
println!("first: after");
return resp;
}
fn second(req: *HttpRequest, c: *Chain) -> HttpResponse {
println!("second: before");
let resp: HttpResponse = chain_next(c);
println!("second: after");
return resp;
}
// r.use_mw(first); r.use_mw(second);
With first registered before second, a request prints:
first: before
second: before
<handler runs>
second: after
first: after
So the first-registered middleware is the outermost layer — register cross-cutting concerns like CORS and request logging early.
Scope-level middleware
Router::scope mounts a sub-router under a prefix (you met it above). The sub-router's own use_mw middleware becomes per-route middleware: it runs after the parent's chain and before the handler. This is how you apply auth to one group of routes without affecting the rest.
import stdlib::http::*;
import stdlib::http::router::*;
fn get_user(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().text(req.param("id"));
}
fn logger(req: *HttpRequest, c: *Chain) -> HttpResponse {
println!("[", req.method, "] ", req.path);
return chain_next(c);
}
fn auth_gate(req: *HttpRequest, c: *Chain) -> HttpResponse {
if req.header("Authorization").len() == 0 {
return HttpResponse::with_status(401).text("unauthorized");
}
return chain_next(c);
}
fn main() -> i32 {
let api: *Router = Router::new();
api.use_mw(auth_gate); // applies only under the scope
api.get("/users/:id", get_user);
let r: *Router = Router::new();
defer r.free();
r.use_mw(logger); // applies to everything
r.scope("/api/v1", api); // logger -> auth_gate -> get_user
return 0;
}
A request to /api/v1/users/42 runs logger, then auth_gate, then get_user. Scopes nest, and middleware accumulates as you descend.
CORS as middleware
The stdlib::http::cors module ships a ready-made middleware. You configure it once with install_cors, then register cors_mw like any other middleware:
pub struct CorsConfig {
pub origin: string,
pub methods: string,
pub headers: string,
pub credentials: bool,
pub max_age: i32,
}
pub fn install_cors(cfg: CorsConfig)
pub fn cors_mw(req: *HttpRequest, c: *Chain) -> HttpResponse
cors_mw short-circuits an OPTIONS preflight with a 204 and the configured headers, and on every other request it runs the chain and attaches the Access-Control-* headers to the response on the way out. The quickest start is the permissive preset (CorsConfig::permissive() — origin "*", the common verbs, Content-Type, Authorization):
import stdlib::http::*;
import stdlib::http::router::*;
import stdlib::http::cors::*;
fn index(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().json("{\"ok\":true}");
}
fn main() -> i32 {
install_cors(CorsConfig::permissive());
let r: *Router = Router::new();
defer r.free();
r.use_mw(cors_mw); // register early so headers cover everything
r.get("/data", index);
return 0;
}
For production, build a CorsConfig explicitly — a specific origin, the verbs you actually allow, credentials, and a preflight cache duration:
install_cors(CorsConfig {
origin: "https://app.example.com",
methods: "GET, POST, OPTIONS",
headers: "Content-Type, Authorization",
credentials: true,
max_age: 3600,
});
Putting it together
A typical server layers global concerns (CORS, logging) on the root router and gates a route group with a scoped auth middleware:
import stdlib::http::*;
import stdlib::http::router::*;
import stdlib::http::cors::*;
fn health(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().text("up");
}
fn secret(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().json("{\"secret\":42}");
}
fn logger(req: *HttpRequest, c: *Chain) -> HttpResponse {
let resp: HttpResponse = chain_next(c);
println!(req.method, " ", req.path, " -> ", resp.status);
return resp;
}
fn require_token(req: *HttpRequest, c: *Chain) -> HttpResponse {
if req.header("Authorization").len() == 0 {
return HttpResponse::with_status(401).text("unauthorized");
}
return chain_next(c);
}
fn main() -> !i32 {
install_cors(CorsConfig::permissive());
let api: *Router = Router::new();
api.use_mw(require_token);
api.get("/secret", secret);
let r: *Router = Router::new();
defer r.free();
r.use_mw(cors_mw); // outermost: CORS on every response
r.use_mw(logger); // then request logging
r.get("/health", health); // open
r.scope("/api", api); // /api/secret also runs require_token
r.listen(8080)?;
return ok(0);
}
GET /health runs cors_mw -> logger -> health. GET /api/secret runs cors_mw -> logger -> require_token -> secret, returning 401 when the Authorization header is missing.
Declarative routing with attributes
The Router API is explicit: you call Router::new(), register each handler with r.get(...), and end with r.listen(...). That is exactly what runs, but it is also a lot of plumbing for what is really a list of "this function answers GET /users/:id" facts.
Glide ships an attribute DSL — a set of compile-time macros in stdlib::http::route — that lets you annotate the handler itself and have the registration code generated for you. The attributes are sugar over the same `Router` methods; nothing magic happens at runtime. Understanding what each one expands to is the key to using them confidently (and to unit-testing routes without opening a socket).
You will almost always pair the route module with the core types and the router itself:
import stdlib::http::*; // HttpRequest, HttpResponse
import stdlib::http::router::*; // Router, Chain, chain_next
import stdlib::http::route::*; // @route/@get/.../@listen, routes!
import stdlib::http::handler::*; // @handler wrapping used by @route
The route attributes
@route(METHOD, "/path") is the general form. METHOD is a bare identifier (GET, POST, …, case-insensitive — the macro lower-cases it) and the path is a string literal. There are method-specific sugar attributes that take just the path:
| Attribute | Expands to | HTTP method |
|---|---|---|
@route(GET, "/p") |
r.get("/p", handler) |
as named |
@get("/p") |
r.get("/p", handler) |
GET |
@post("/p") |
r.post("/p", handler) |
POST |
@put("/p") |
r.put("/p", handler) |
PUT |
@delete("/p") |
r.delete("/p", handler) |
DELETE |
@patch("/p") |
r.patch("/p", handler) |
PATCH |
@head("/p") |
r.head("/p", handler) |
HEAD |
@options("/p") |
r.options("/p", handler) |
OPTIONS |
@any("/p") |
r.any("/p", handler) |
every method |
What does a route attribute actually emit? From the source (route.glide), the impl_route macro:
- Keeps your original function, renamed to
_user_<name>. - Wraps it exactly the way
@handlerdoes — typed-param extraction (Path<T>,Query<T>, …) into a plainfn(*HttpRequest) -> HttpResponse. - Emits a registration function:
fn _route_register_<name>(r: *Router) {
r.<method>("<path>", <wrapped_handler>);
}
So @get("/") on fn index(...) produces a _route_register_index(r) that calls r.get("/", ...). The macro never calls listen and never builds a Router — that is the job of @listen or routes!.
Two compile-time checks run while expanding (both surface as error: diagnostics):
- Path shape (
_validate_path): must start with/, no empty//segments. - Param alignment (
_validate_path_params): every:idsegment needs a matchingid: Path<T>parameter, and*restarest: Path<string>— unless the handler takes a singlereq: *HttpRequest, in which case you have opted out of typed extraction and will read params withreq.param("id")yourself.
@listen / @listen_workers — generate main
@listen(port) and @listen_workers(port, n) go on fn main(r: *Router) { ... }. The macro replaces the body of `main` with the full server bootstrap (impl_listen in route.glide):
// what @listen(8080) on `fn main(r: *Router)` expands to, roughly:
fn main() -> !i32 {
let r: *Router = Router::new();
/* ...your original main body runs here, with `r` in scope... */
_route_register_index(r); // one call per @route'd fn in the program
_route_register_echo(r);
return r.listen(8080);
}
Things worth knowing:
- The generated
mainreturns!i32, so abind failedfromlistenpropagates to a non-zero exit code. You do not write the return type yourself. - Your own body (the part you wrote between the braces) runs after
Router::new()and before registration, withralready in scope. Use it forr.use_mw(...)global middleware, a customr.not_found_with(...), etc. - Every
@route'd function in the whole program is auto-registered — you do not list them. Declaring two routes with the samemethod|pathis a compile-timeduplicate routeerror. @listen(port)callsr.listen(port)(single-threaded).@listen_workers(port, n)callsr.listen_workers(port, n). Writing@listen_workers(port)with nondefaults to 4 workers.
Here is a complete server. @get and @post annotate the handlers; @listen turns main into the bootstrap. Note the empty body — there is nothing to do here but listen.
import stdlib::http::*;
import stdlib::http::router::*;
import stdlib::http::route::*;
import stdlib::http::handler::*;
@get("/")
fn index(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().text("hello");
}
@post("/echo")
fn echo(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().text(req.body);
}
@listen(8080)
fn main(r: *Router) {
}
The signatures these expand into are the real Router methods:
pub fn get(self: *Router, pattern: string,
handler: fn(*HttpRequest) -> HttpResponse)
pub fn listen(self: *Router, port: i32) -> !i32
pub fn listen_workers(self: *Router, port: i32, n: i32) -> !i32
@middleware(...) — route-scoped middleware
Stack one or more middlewares onto a single route by adding a sibling @middleware(a, b, ...) attribute. The arguments are middleware function idents — each has the signature fn(*HttpRequest, *Chain) -> HttpResponse and calls chain_next(c) to proceed.
When a @middleware is present, the route attribute changes its emitted registration from r.<method>(...) to r.route_with(...):
fn _route_register_admin(r: *Router) {
let mws = Vector::new();
mws.push(logger);
mws.push(require_auth);
r.route_with("GET", "/admin", mws, <wrapped_handler>);
}
A complete example — logger always runs, require_auth short-circuits with 401 when there is no Authorization header:
import stdlib::http::*;
import stdlib::http::router::*;
import stdlib::http::route::*;
import stdlib::http::handler::*;
fn logger(req: *HttpRequest, c: *Chain) -> HttpResponse {
println!("[", req.method, "] ", req.path);
return chain_next(c);
}
fn require_auth(req: *HttpRequest, c: *Chain) -> HttpResponse {
if req.header("Authorization").eq("") {
return HttpResponse::with_status(401).text("no token");
}
return chain_next(c);
}
@get("/admin")
@middleware(logger, require_auth)
fn admin(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().text("welcome, admin");
}
@listen(8080)
fn main(r: *Router) {
}
routes!(r) — explicit registration (and how to unit-test)
@listen is convenient but it owns main and binds a socket. When you want to register into a Router you built — to add custom middleware, to compose sub-routers, or above all to test routes without listening — use the routes!(r) macro instead.
routes!(r) walks the program for every @route'd function and emits the same _route_register_<name>(r); calls inline (same duplicate-route check applies). It does not build the router or call listen — you do both. Because dispatch is just r.dispatch(&req) returning an HttpResponse, you can build an HttpRequest by hand and assert on the result — no network, no port. This is the idiomatic way to unit-test handlers:
import stdlib::http::*;
import stdlib::http::router::*;
import stdlib::http::route::*;
import stdlib::http::handler::*;
import stdlib::hashmap::*;
@get("/")
fn index(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().text("hello");
}
@post("/echo")
fn echo(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().text(req.body);
}
fn main() -> i32 {
let r: *Router = Router::new();
routes!(r);
// r.use_mw(...); // add global middleware here if you like
// Drive a request through the router with no socket in sight.
let req: HttpRequest = HttpRequest {
method: "GET",
path: "/",
version: "HTTP/1.1",
headers_block: "",
body: "",
params: null,
queries: null,
tls: false,
headers_cache: null,
};
let resp: HttpResponse = r.dispatch(&req);
println!("status:", resp.status); // 200
println!("body:", resp.body); // hello
return 0;
}
Typed handlers with @handler
The router stores handlers as one fixed shape: fn(*HttpRequest) -> HttpResponse. That is honest but tedious — every handler has to reach into the request by hand, parse the body, look up path params, and build a response object. The @handler macro (from stdlib::http::handler) gives you Axum-style ergonomics on top of that shape: you write a function whose parameters are typed extractors and whose return is a typed value, and the macro reads your signature and emits the boilerplate for you.
Concretely, @handler does two things:
- It renames your function to
_user_<name>(keeping your typed body intact, so you can still call it directly). - It emits a wrapper named
<name>signed exactly asfn(*HttpRequest) -> HttpResponse, which extracts each parameter from the request and calls your renamed function.
Because the wrapper keeps the original name and the router-required shape, r.get("/users/:id", get_user) still type-checks with no widening.
A first handler: Path and Json
Here is the smallest useful pair — a read by typed path param, and a write that binds a JSON body and returns a typed value. Note that Path<T> lives in stdlib::http::extract and Json<T> in stdlib::http::typed.
import stdlib::http::*;
import stdlib::http::router::*;
import stdlib::http::handler::*;
import stdlib::http::typed::*;
import stdlib::http::extract::*;
import stdlib::json::*;
pub struct User { pub id: i32 }
impl JsonBind for User {
fn from_json(v: *JsonValue) -> !User { return ok(User { id: v.get_int("id")? }); }
fn to_json(self: *User) -> *JsonValue {
let o: *JsonValue = JsonValue::object();
o.obj_set("id", JsonValue::int(self.id));
return o;
}
}
// GET /users/:id — the param ident `id` is the path-key lookup.
@handler
fn get_user(id: Path<i32>) -> HttpResponse {
return HttpResponse::ok().text("user #".concat(id.val.to_string()));
}
// POST /users — body bound from JSON; returns Json<User> (auto-serialized).
@handler
fn create_user(body: Json<User>) -> Json<User> {
return Json::wrap(body.val);
}
fn main() -> i32 {
let r: *Router = Router::new();
r.get("/users/:id", get_user);
r.post("/users", create_user);
return 0;
}
Here User is a minimal struct implementing JsonBind (from_json / to_json) — that trait is covered in depth in the JSON section below. The key thing: get_user looks like it takes a Path<i32>, but after the macro runs it really is a fn(*HttpRequest) -> HttpResponse, so the router accepts it.
Parameter dispatch order
The macro walks your parameters left to right and picks an extraction strategy per type. The order matters because the first matching rule wins:
| Parameter type | What the macro emits | Notes |
|---|---|---|
req: *HttpRequest |
passthrough — req is forwarded directly |
no extraction at all |
body: Json<T> |
req.body_json() then T::from_json(...) |
built-in; 400 on parse, 422 on bind (T: JsonBind) |
name: Path<T> |
Path::extract(req, "name") |
param ident is the path key (T: FromPath) |
name: Query<T> |
Query::extract(req, "name") |
param ident is the query key (T: FromPath) |
state: State<T> |
State::extract(req) |
reads the slot set by Router::state(...) |
any other T |
T::from_request(req) |
via the FromRequest trait |
Json<T> is kept as a hand-rolled special case (rather than going through FromRequest) because of a monomorphisation collision between Json<T>'s static from_request and its inherent wrap method. The behaviour is identical from your side. The full extractor catalog — Bearer, Headers, Cookies, Form, and friends — is the subject of the next section.
Mixing passthrough and extractors
You can take the raw request and typed params in the same signature. The *HttpRequest passthrough is forwarded with no extraction, while the typed params are pulled from the request:
@handler
fn detail(req: *HttpRequest, id: Path<i32>) -> HttpResponse {
return HttpResponse::ok().text(
req.method.concat(" id=").concat(id.val.to_string()));
}
This is handy when you mostly want typed access but still need one or two fields off the raw request.
Return shapes
The macro inspects your declared return type to decide how to finish:
| Return type | What happens |
|---|---|
HttpResponse |
passthrough — your value is returned as-is |
Json<T> |
the macro appends .into_response(), which calls T.to_json() and sets Content-Type: application/json |
So returning Json::wrap(body.val) from a -> Json<T> handler produces a 200 with a JSON body automatically — you never touch HttpResponse for the happy path. The conversion goes through the IntoResponse trait's Json<T> impl, which is HttpResponse::ok().json_of(self.val.to_json()).
@get / @post wrap the same way
The routing attributes — @get, @post, and friends from stdlib::http::route — call the same _emit_handler_wrapper internally. That means a routed handler can also take typed parameters and return Json<T>; you get the wrapping for free, plus auto-registration via routes!:
import stdlib::http::*;
import stdlib::http::router::*;
import stdlib::http::route::*;
import stdlib::http::typed::*;
import stdlib::http::extract::*;
import stdlib::json::*;
pub struct User { pub id: i32 }
impl JsonBind for User {
fn from_json(v: *JsonValue) -> !User { return ok(User { id: v.get_int("id")? }); }
fn to_json(self: *User) -> *JsonValue {
let o: *JsonValue = JsonValue::object();
o.obj_set("id", JsonValue::int(self.id));
return o;
}
}
@get("/users/:id")
fn show(id: Path<i32>) -> HttpResponse {
return HttpResponse::ok().text("user #".concat(id.val.to_string()));
}
@post("/users")
fn create(body: Json<User>) -> Json<User> {
return Json::wrap(body.val);
}
fn main() -> i32 {
let r: *Router = Router::new();
routes!(r); // self-registers every @route'd fn in the program
return 0;
}
Whether you reach for bare @handler plus manual r.get(...), or @get/@post plus routes!, the typed-parameter and typed-return mechanics are identical.
curl -X POST localhost:8080/users -d 'not json' # 400 parse error
curl -X POST localhost:8080/users -d '{"name":"x"}' # 422 missing field
curl localhost:8080/users/abc # 400 (Path<i32> didn't convert)
Extractors: pulling typed data from a request
A handler shouldn't have to fish raw strings out of *HttpRequest by hand, parse them, and decide what status to return when they're missing. That's what extractors are for: each one knows how to pull one specific piece of data out of a request, convert it to a real Glide type, and — when the data is absent or malformed — produce the right HTTP status. You declare extractors as handler parameters and @handler wires them up for you.
Everything in this section lives in one module:
import stdlib::http::extract::*;
The FromRequest trait
Every extractor implements one trait. This is the contract @handler calls for each parameter:
pub trait FromRequest {
/// Build `Self` from `req`. Err string carries the status as
/// `"<code>:<message>"` (e.g. `"401:missing token"`); plain
/// strings default to 422 in the wrapper.
fn from_request(req: *HttpRequest) -> !Self;
}
The return is a Result (!Self). On failure the err string doubles as a status carrier: an extractor prefixes it with "<status>:", e.g. "401:missing token" or "404:missing path param: id". When @handler sees a failed extraction it feeds that err to HttpResponse::from_extract_err, which parses the prefix into a status and body. A plain (unprefixed) err defaults to 422 Unprocessable Entity. *HttpRequest itself implements FromRequest (as a passthrough), so a handler can always just ask for the whole request.
The status codes the built-ins use:
| Code | When |
|---|---|
| 400 | body failed to parse / a Path/Query value didn't convert |
| 422 | JSON bound but a field was missing/invalid; also the default for unprefixed errors |
| 401 | missing or wrong auth (Bearer, Authorization<S>) |
| 404 | a :name path segment was absent |
| 415 | wrong Content-Type for Form / MultipartForm |
Writing your own extractor
Because it's just a trait, you can add a custom extractor for your own type — say an API-key check — and use it as a handler param exactly like the built-ins. Extractors run in parameter order and short-circuit on the first failure, so encode the status in the err prefix:
import stdlib::http::*;
import stdlib::http::extract::*;
import stdlib::http::handler::*;
pub struct ApiKey { pub key: string }
impl FromRequest for ApiKey {
fn from_request(req: *HttpRequest) -> !ApiKey {
let v: string = req.header("X-Api-Key");
if v.len() == 0 { return err("401:missing X-Api-Key"); }
return ok(ApiKey { key: v });
}
}
@handler
fn guarded(key: ApiKey) -> HttpResponse {
return HttpResponse::ok().text("key=".concat(key.key));
}
You can also drive an extractor by hand — useful inside middleware or a plain *HttpRequest-shaped handler. The pattern mirrors what the macro emits:
fn handle_manually(req: *HttpRequest) -> HttpResponse {
let r: !Bearer = Bearer::from_request(req);
if !r.ok { return HttpResponse::from_extract_err(r.err); }
return HttpResponse::ok().text("ok ".concat(r.val.token));
}
Path and query params: FromPath
Path<T> and Query<T> are generic over a small conversion trait. Where FromRequest turns a whole request into a value, FromPath turns a single URL scalar (a :segment or a ?key=value) into a typed value:
pub trait FromPath {
fn from_path(s: string) -> !Self;
}
The stdlib ships three impls. Their conversion rules:
T |
Accepts | Err (→ 400) |
|---|---|---|
string |
anything (identity) | never |
i32 |
whatever try_parse_int accepts |
not an int: <s> |
bool |
"true"/"1" and "false"/"0" |
not a bool: <s> |
Both Path<T> and Query<T> reuse FromPath for conversion, so the same three target types work in either position. Want a custom type in a route? Add an impl FromPath for YourType.
The extractor catalog
Every built-in extractor, what it reads, how you read it back, and the status it produces when it can't:
| Type | Pulls from | Read it via | Status on failure |
|---|---|---|---|
*HttpRequest |
the whole request | the pointer itself | never (passthrough) |
Path<T> |
a :name route segment, keyed by the param name |
.val (type T) |
404 if missing, 400 if T conversion fails |
Query<T> |
a ?name=value query param, keyed by the param name |
.val (type T) |
400 if missing or conversion fails |
State<T> |
the router's shared state slot (r.state(p)) |
.val (type *T) |
500 if no state was registered |
Headers |
the request header bag | .get(name) -> string, .has(name) -> bool |
never (missing header → "") |
Bearer |
Authorization: Bearer <token> |
.token (string) |
401 if header missing or scheme isn't Bearer |
Basic |
Authorization: Basic <user:pass> (as an AuthScheme) |
.user, .pass |
401 via the Authorization<S> wrapper |
Authorization<S> |
the Authorization header, parsed by scheme S |
.scheme (type S) |
401 if missing or S::parse fails |
Cookies |
the Cookie: request header |
.get(name) -> string, .has(name) -> bool |
never (missing cookie → "") |
Form |
a x-www-form-urlencoded body |
.get(name) -> string, .has(name) -> bool |
415 wrong Content-Type, 400 if body won't parse |
Path<T> and Query<T>
import stdlib::http::*;
import stdlib::http::extract::*;
import stdlib::http::handler::*;
// route /users/:id
@handler
fn get_user(id: Path<i32>) -> HttpResponse {
return HttpResponse::ok().text("user #".concat(id.val.to_string()));
}
// GET /search?page=2&q=glide
@handler
fn search(page: Query<i32>, q: Query<string>) -> HttpResponse {
return HttpResponse::ok().text(
"page=".concat(page.val.to_string()).concat(" q=").concat(q.val));
}
// route /toggle/:active -> /toggle/true
@handler
fn flag(active: Path<bool>) -> HttpResponse {
if active.val { return HttpResponse::ok().text("on"); }
return HttpResponse::ok().text("off");
}
If the path segment is absent the handler never runs — Path<T> returns 404. If it's present but T::from_path rejects it (e.g. /users/abc for Path<i32>), it's a 400. A missing query param is a 400 (the value was expected and isn't optional).
Headers, Bearer, Basic, and Authorization<S>
Headers is the low-ceremony way to read arbitrary headers. It pins the request pointer and forwards to req.header(name) — a case-insensitive lookup that returns "" when the header is absent:
pub fn get(self: *Headers, name: string) -> string // case-insensitive, "" if absent
pub fn has(self: *Headers, name: string) -> bool // present and non-empty
For auth, Bearer strips the Bearer prefix and hands you the bare token in .token. Basic splits user:pass (note: v1 does not base64-decode — it treats the value after Basic as the literal user:pass). Both Bearer and Basic implement AuthScheme, so Authorization<Bearer> and Authorization<Basic> give you the same data through a uniform wrapper whose .scheme field is the parsed value:
import stdlib::http::*;
import stdlib::http::extract::*;
import stdlib::http::handler::*;
@handler
fn protected(auth: Bearer) -> HttpResponse {
return HttpResponse::ok().text("token=".concat(auth.token));
}
@handler
fn echo_ua(headers: Headers) -> HttpResponse {
if !headers.has("User-Agent") {
return HttpResponse::with_status(400).text("no UA");
}
return HttpResponse::ok().text(headers.get("User-Agent"));
}
@handler
fn login(creds: Authorization<Basic>) -> HttpResponse {
return HttpResponse::ok().text(
creds.scheme.user.concat(":").concat(creds.scheme.pass));
}
A missing or wrong-scheme Authorization header is a 401 in every case.
Cookies
Cookies wraps the Cookie: request header (read-only — to set a cookie use HttpResponse::cookie(...) on the response). .get walks the header string and returns "" for an absent cookie; .has is the presence check:
import stdlib::http::*;
import stdlib::http::extract::*;
import stdlib::http::handler::*;
@handler
fn me(cookies: Cookies) -> HttpResponse {
let sid: string = cookies.get("session");
if sid.eq("") { return HttpResponse::with_status(401).text("login"); }
return HttpResponse::ok().text("session=".concat(sid));
}
Because a missing cookie is just "", Cookies never fails extraction — you decide what an empty cookie means and pick the status yourself.
State<T>: shared application state
State<T> gives a handler typed access to whatever you registered with Router::state(p) — no globals, no closure capture. The state slot holds a *void; State<T> casts it back to *T for you, reachable via .val:
impl<T> State<T> {
pub fn extract(req: *HttpRequest) -> !State<T> { ... } // 500 if no state set
}
Like Path<T>, State<T> is special-cased by the macro, so you just declare the param. If you forgot to call r.state(...) before listening, the slot is empty and extraction returns 500 (a startup bug, not a client error).
import stdlib::http::*;
import stdlib::http::router::*;
import stdlib::http::handler::*;
import stdlib::http::extract::*;
pub struct Counter {
pub hits: i32,
}
@handler
fn stats(state: State<Counter>) -> HttpResponse {
return HttpResponse::ok().text("hits=".concat(state.val.hits.to_string()));
}
fn main() -> i32 {
let c: *Counter = new Counter { hits: 0 };
let r: *Router = Router::new();
r.state(c as *void);
r.get("/stats", stats);
return 0;
}
Putting it together: State<T> + Bearer + Query<T>
A handler can mix as many extractors as it likes; @handler runs them in order and short-circuits to the first failure's status. Here a single handler reads shared state, validates a bearer token, and parses a query param — all typed:
import stdlib::http::*;
import stdlib::http::router::*;
import stdlib::http::extract::*;
import stdlib::http::handler::*;
struct Db(name: string)
@handler
fn list_users(state: State<Db>, auth: Bearer, page: Query<i32>) -> HttpResponse {
if auth.token.eq("") {
return HttpResponse::with_status(403).text("forbidden");
}
return HttpResponse::ok().text(
"db=".concat(state.val.name)
.concat(" page=").concat(page.val.to_string()));
}
fn main() -> i32 {
let r: *Router = Router::new();
let db: *Db = malloc(sizeof(Db)) as *Db;
db.name = "users";
r.state(db as *void);
return 0;
}
If the request has no Authorization: Bearer …, the handler body never runs and the client gets 401 — straight from the Bearer extractor. If ?page= is missing, it's a 400. If state was never registered, 500. Each extractor owns its own failure mode, so your handler body only ever sees fully-valid, typed data. (Form and MultipartForm are extractors too — they get their own treatment under static files and uploads.)
Working with JSON bodies
Most JSON APIs do the same dance on every request: parse the body, check it's the shape you expect, pull out the fields, and serialize a typed value back on the way out. Glide gives you one trait — JsonBind — and a thin wrapper — Json<T> — that turn that ceremony into a couple of lines. You teach the compiler how your struct moves to and from JSON once, and the typed-handler machinery does the rest.
Everything here builds on the raw JSON value tree from stdlib::json (the JsonValue type with its get_string / get_int / obj_set / emit accessors). The short version: JsonValue is a tagged union (kind is one of the JSON_* constants), and the typed accessors return !T / ?T so you can propagate or default cleanly.
The JsonBind trait
JsonBind lives in stdlib::http::typed and is the contract for "this struct knows how to read and write itself as JSON":
pub trait JsonBind {
fn from_json(v: *JsonValue) -> !Self;
fn to_json(self: *Self) -> *JsonValue;
}
from_jsontakes a parsed*JsonValueand returns!Self—ok(value)on success,err("…")when the JSON is the wrong shape. The error string is what the client will see as the 422 body.to_jsontakesselfby pointer and builds a*JsonValueto emit.
You implement both by hand. Inside from_json you typically guard the kind and then use the typed object accessors, propagating field failures with ?:
| Accessor | Returns | On missing / wrong type |
|---|---|---|
v.get_string("k") |
!string |
err (use with ?) |
v.get_int("k") |
!i32 |
err (use with ?) |
v.get_bool("k") |
!bool |
err (use with ?) |
v.get_float("k") |
!f64 |
err (int promotes to f64) |
v.opt_string("k") |
?string |
none() |
v.opt_int("k") |
?i32 |
none() |
v.get_object("k") |
!*JsonValue |
err — drill further |
v.get_array("k") |
!*JsonValue |
err — walk with len()/at(i) |
v.get_any("k") |
*JsonValue |
null — dynamic pass-through |
A required string with an optional integer alongside it:
import stdlib::http::*;
import stdlib::http::typed::*;
import stdlib::json::*;
pub struct Pet {
pub name: string,
pub age: ?i32,
}
impl JsonBind for Pet {
fn from_json(v: *JsonValue) -> !Pet {
if v.kind != JSON_OBJECT { return err("expected object"); }
return ok(Pet {
name: v.get_string("name")?, // required: 422 if absent
age: v.opt_int("age"), // optional: none() if absent
});
}
fn to_json(self: *Pet) -> *JsonValue {
let v: *JsonValue = JsonValue::object();
v.obj_set("name", JsonValue::string(self.name));
if self.age.has {
v.obj_set("age", JsonValue::int(self.age.val));
}
return v;
}
}
Json::wrap, json_respond, and the IntoResponse path
Once a type implements JsonBind, three pieces turn a value into a response. Json<T> is the carrier; the rest are sugar over it:
pub struct Json<T> {
pub val: T,
}
impl<T: JsonBind> Json<T> {
pub fn wrap(v: T) -> Json<T> { /* Json { val: v } */ }
}
pub fn json_respond<T: JsonBind>(v: T) -> HttpResponse { /* wrap + into_response */ }
The conversion to an HttpResponse runs through the IntoResponse trait, also in stdlib::http::typed:
impl<T: JsonBind> IntoResponse for Json<T> {
fn into_response(self: Json<T>) -> HttpResponse {
return HttpResponse::ok().json_of(self.val.to_json());
}
}
json_of (the HttpResponse builder you met earlier) sets Content-Type: application/json and emits the value's to_json() tree as the body, so a Json<T> reply is always a 200 with the right header. IntoResponse is also implemented for HttpResponse itself (identity), which is why a handler can mix typed and raw replies on different branches. These three spellings are equivalent — pick by taste:
return json_respond(pet); // shortest
return Json::wrap(pet).into_response(); // explicit
return HttpResponse::ok().json_of(pet.to_json()); // fully manual
Putting binding and responding together in a plain fn(*HttpRequest) -> HttpResponse handler shows where each status code comes from. req.body_json() returns !*JsonValue (it's err on an empty or malformed body), so you map that to 400, and a failed from_json bind to 422:
fn create_pet(req: *HttpRequest) -> HttpResponse {
let body_r: !*JsonValue = req.body_json();
if !body_r.ok {
return HttpResponse::with_status(400).text(body_r.err); // bad JSON
}
let pet_r: !Pet = Pet::from_json(body_r.val);
if !pet_r.ok {
return HttpResponse::with_status(422).text(pet_r.err); // bad shape
}
let pet: Pet = pet_r.val;
return json_respond(pet);
}
That two-step (400 then 422) is the canonical pattern — and it is exactly what the @handler macro generates for you when a parameter is typed Json<T>.
Typed handlers: Json<T> in, Json<T> out
Writing the 400/422 ladder by hand on every endpoint gets old. When a parameter is typed Json<T>, the @handler wrapper runs precisely the ceremony above — req.body_json() (400 on parse failure), T::from_json(...) (422 on bind failure), then hands your function the typed value. When the return type is Json<T>, the wrapper appends .into_response(), so you just return Json::wrap(value) and get a 200 application/json.
Here is a full @post endpoint that takes a typed body and echoes a typed response, wired into a server with @listen:
import stdlib::http::*;
import stdlib::http::router::*;
import stdlib::http::route::*;
import stdlib::http::handler::*;
import stdlib::http::typed::*;
import stdlib::json::*;
pub struct CreateUser {
pub name: string,
pub age: i32,
}
impl JsonBind for CreateUser {
fn from_json(v: *JsonValue) -> !CreateUser {
if v.kind != JSON_OBJECT { return err("expected object"); }
return ok(CreateUser {
name: v.get_string("name")?,
age: v.get_int("age")?,
});
}
fn to_json(self: *CreateUser) -> *JsonValue {
let v: *JsonValue = JsonValue::object();
v.obj_set("name", JsonValue::string(self.name));
v.obj_set("age", JsonValue::int(self.age));
return v;
}
}
// Body parses as Json<CreateUser> (400/422 handled by the macro).
// Return Json<CreateUser> is auto-converted via IntoResponse to a
// 200 with Content-Type: application/json.
@handler
@post("/users")
fn create_user(body: Json<CreateUser>) -> Json<CreateUser> {
let u: CreateUser = body.val;
return Json::wrap(CreateUser { name: u.name, age: u.age });
}
@listen(8080)
fn main() {
}
A round-trip against it:
curl -s -X POST localhost:8080/users \
-H 'content-type: application/json' \
-d '{"name":"alice","age":30}'
# {"name":"alice","age":30}
The status codes fall out of the body for free:
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/users -d 'not json'
# 400 (body_json failed to parse)
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/users \
-H 'content-type: application/json' -d '{"name":"alice"}'
# 422 (from_json: missing required "age")
Collections: Vector<T> is JsonBind for free
You don't need to implement JsonBind for a list of bindable values — the stdlib ships a blanket impl: any Vector<T> where T: JsonBind is itself JsonBind. It maps a JSON array to a Vector<T> and back, propagating the first bad element's error through ? (no silent skipping; non-array input errors with "expected array").
let xs: *Vector<Item> = Vector::new();
xs.push(Item { sku: "a1", qty: 2 });
xs.push(Item { sku: "b2", qty: 5 });
let s: string = (xs).to_json().emit();
// [{"sku":"a1","qty":2},{"sku":"b2","qty":5}]
let back: Vector<Item> = Vector::from_json(JsonValue::parse(s))?;
That means a handler returning Json<Vector<Item>> (with @handler) or calling json_respond(items) serializes a JSON array with no extra code — as long as the element type binds.
Streaming responses and Server-Sent Events
Not every response fits in memory. A multi-gigabyte export, a live log tail, or a chat feed should leave the server as it is produced rather than being buffered into one big body string. Glide handles this with chunked transfer encoding: instead of setting a body, you hand the response a source that the server polls for the next slice of bytes, framing each one as a Transfer-Encoding: chunked chunk on the wire.
Server-Sent Events (SSE) are a thin, standardized layer on top of that same mechanism — a one-way text/event-stream that browsers consume with the EventSource API.
The ChunkSource trait
Streaming bodies are driven by a single-method trait in stdlib::http:
pub trait ChunkSource {
fn next_chunk(self: *Self) -> ?string;
}
The server calls next_chunk repeatedly. Each some(s) is written as one chunk; none() ends the stream. Glide has no closures, so the source holds its own state in a struct — typically a cursor plus whatever it iterates over — and the impl walks that state forward on each call.
You attach a source with HttpResponse.stream:
pub fn stream(self: HttpResponse, source: *dyn ChunkSource) -> HttpResponse;
Note the parameter type: *dyn ChunkSource. You pass a pointer to your concrete struct, cast with as *dyn ChunkSource. Once a stream source is set, the buffered body is ignored and the server emits chunked encoding instead of Content-Length.
Here is a complete server that streams three lines, one chunk each:
import stdlib::http::*;
pub struct Lines {
pub items: *Vector<string>,
pub idx: i32,
}
impl ChunkSource for Lines {
fn next_chunk(self: *Lines) -> ?string {
if self.idx >= self.items.len() { return none(); }
let s: string = self.items.get(self.idx);
self.idx = self.idx + 1;
return some(s.concat("\n"));
}
}
fn report(_req: *HttpRequest) -> HttpResponse {
let items: *Vector<string> = Vector::new();
items.push("alpha");
items.push("beta");
items.push("gamma");
let src: *Lines = malloc(sizeof(Lines)) as *Lines;
src.items = items;
src.idx = 0;
return HttpResponse::ok()
.set("Content-Type", "text/plain")
.stream(src as *dyn ChunkSource);
}
fn main() -> !i32 {
http_listen_workers_blocking(8080, 4, report)?;
return ok(0);
}
Why streaming handlers must run on the blocking server
A streaming handler does not finish when it returns the HttpResponse. The server then sits in a loop pumping next_chunk until it sees none(), and your source may block in between — waiting on a channel, a timer, or new log lines. That parking behaviour is incompatible with the fast non-blocking state-machine path.
Run streaming and SSE handlers on the coroutine-per-connection server:
pub fn http_listen_workers_blocking(port: i32, n: i32,
handler: fn(*HttpRequest) -> HttpResponse) -> !i32;
(Router exposes the same idea as r.listen_workers_blocking(...).) Using the plain http_listen for a blocking source will stall the event loop.
Server-Sent Events
SSE lives in stdlib::http::sse. An event maps directly to the wire format:
pub struct SseEvent {
pub event: string, // `event:` line — optional name
pub id: string, // `id:` line — optional, echoed back as Last-Event-ID
pub data: string, // `data:` payload (multi-line allowed)
pub retry: i32, // `retry:` ms before client reconnect (0 = omit)
}
Two constructors cover the common cases:
| Constructor | Builds | Wire form |
|---|---|---|
SseEvent::data(payload) |
data-only event | data: payload\n\n |
SseEvent::named(name, payload) |
named event | event: name\ndata: payload\n\n |
For full control (id, retry) build the struct literal directly.
Formatting helpers
You rarely call these by hand when using sse_response — the source does — but they are the building blocks:
| Function | Purpose | Output |
|---|---|---|
sse_format(e: SseEvent) -> string |
serialize an event to wire bytes | event: tick\ndata: 1\n\n |
sse_comment(text: string) -> string |
comment / heartbeat line | : text\n\n |
sse_keepalive() -> string |
minimal heartbeat frame | :\n\n |
sse_last_event_id(req: *HttpRequest) -> string |
client's Last-Event-ID header (empty if absent) |
— |
sse_format emits one data: line per \n in the payload (per spec; the browser rejoins them), and an empty event serializes to a single blank line.
sse_response
Wrap a ChunkSource in a fully-configured SSE response with one call:
pub fn sse_response(source: *dyn ChunkSource) -> HttpResponse;
It sets the headers SSE requires for you:
Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: no (disables nginx proxy buffering)
Connection: keep-alive
A producer-driven SSE endpoint
The most common shape: a struct that generates the next event on each call. This one emits five tick events and supports reconnection via Last-Event-ID — when a browser reconnects after a drop it sends back the last id: it saw, so you can resume.
import stdlib::http::*;
import stdlib::http::sse::*;
pub struct Pulse {
pub idx: i32,
pub total: i32,
}
impl ChunkSource for Pulse {
fn next_chunk(self: *Pulse) -> ?string {
if self.idx >= self.total { return none(); }
self.idx = self.idx + 1;
return some(sse_format(SseEvent::named("tick", self.idx.to_string())));
}
}
fn ticks(req: *HttpRequest) -> HttpResponse {
let last: string = sse_last_event_id(req);
let start: i32 = if last.len() > 0 { last.try_parse_int() ?? 0 } else { 0 };
let p: *Pulse = malloc(sizeof(Pulse)) as *Pulse;
p.idx = start;
p.total = start + 5;
return sse_response(p as *dyn ChunkSource);
}
fn main() -> !i32 {
http_listen_workers_blocking(8080, 4, ticks)?;
return ok(0);
}
On the client side this is just:
curl -N http://localhost:8080/ # -N disables curl's buffering
event: tick
data: 1
event: tick
data: 2
...
Fan-out with SseChannel
When events originate elsewhere in your application — a pub/sub broadcast, a job-progress feed — wrap a channel instead of generating events inline. SseChannel is a ChunkSource that blocks on recv and yields each event a sender pushes:
pub struct SseChannel {
pub ch: *chan<SseEvent>,
}
impl SseChannel {
pub fn new(ch: *chan<SseEvent>) -> *SseChannel;
}
Its next_chunk recvs an event, formats it, and returns none() when it sees a sentinel whose event name equals the SSE_END constant — that is how a producer signals end-of-stream:
pub const SSE_END: string = "__close__";
import stdlib::http::*;
import stdlib::http::sse::*;
fn stream(_req: *HttpRequest) -> HttpResponse {
let ch: *chan<SseEvent> = malloc(sizeof(chan<SseEvent>)) as *chan<SseEvent>;
*ch = make_chan(64);
ch.send(SseEvent::data("hello"));
ch.send(SseEvent::named("chat", "world"));
// A sentinel whose name == SSE_END tells the source to stop.
ch.send(SseEvent { event: SSE_END, id: "", data: "", retry: 0 });
let src: *SseChannel = SseChannel::new(ch);
return sse_response(src as *dyn ChunkSource);
}
fn main() -> !i32 {
http_listen_workers_blocking(8080, 4, stream)?;
return ok(0);
}
In a real broadcast setup the channel is created once and shared, and other coroutines push events into it while the handler holds the read end. The request stays open until the channel delivers an SSE_END event (or the client disconnects, which tears down the writer downstream).
Static files, uploads, and forms
Most real services do more than emit JSON: they ship a CSS bundle, accept a profile photo, process a login form, and shrink large responses on the wire. Glide's HTTP stdlib covers all four with small, focused pieces — serve_dir for whole directories, HttpResponse::file for one-off files, the Multipart builder/parser for uploads, the Form extractor for urlencoded bodies, and gzip_mw for compression.
Serving a whole directory with serve_dir
To expose a directory of assets under a URL prefix, mount it once at startup. The configuration is a plain struct:
// from stdlib::http::static
pub struct StaticOpts {
pub root: string, // directory on disk
pub url_prefix: string, // URL segment files live under
pub cache_control: string, // Cache-Control header (empty = omit)
}
pub fn serve_dir(r: *Router, opts: StaticOpts) { /* ... */ }
root is the directory on disk; url_prefix is the URL path that maps onto it. With url_prefix: "/static" and root: "./public", a GET /static/css/site.css reads ./public/css/site.css. cache_control is emitted verbatim as the Cache-Control response header when non-empty — pass "public, max-age=31536000, immutable" for fingerprinted bundles, or "no-cache" to force revalidation.
import stdlib::http::*;
import stdlib::http::router::*;
import stdlib::http::static::*;
fn main() -> i32 {
let r: *Router = Router::new();
serve_dir(r, StaticOpts {
root: "./public",
url_prefix: "/static",
cache_control: "public, max-age=3600",
});
r.listen_workers(8080, 4);
return 0;
}
Internally serve_dir registers a single wildcard route — GET /<url_prefix>/*filepath — and serves files via the same zero-copy HttpResponse::file path described below. You get production basics for free: path-traversal protection (requests containing .. segments, a leading /, or a NUL byte get a 403), a weak ETag derived from file size so browsers can revalidate with If-None-Match and receive a 304, and index.html resolution for directory paths.
Sending a single file with HttpResponse::file
When you want a handler to return one specific file — a generated report, a download, a templated index.html — build the response directly. You supply the MIME type yourself:
// from stdlib::http
pub fn file(path: string, mime: string) -> HttpResponse
import stdlib::http::*;
import stdlib::http::router::*;
fn download(req: *HttpRequest) -> HttpResponse {
return HttpResponse::file("./reports/q1.pdf", "application/pdf");
}
fn home(req: *HttpRequest) -> HttpResponse {
return HttpResponse::file("./public/index.html", "text/html; charset=utf-8");
}
fn main() -> i32 {
let r: *Router = Router::new();
r.get("/report", download);
r.get("/", home);
r.listen(8080);
return 0;
}
The body is streamed straight from disk (sendfile/TransmitFile), so the file never round-trips through a Glide string. serve_dir chooses the MIME type from the file extension for you; with HttpResponse::file you pick it explicitly.
Building a multipart body
To send a file — from a Glide client, a test, or a service-to-service call — assemble a multipart/form-data body with the Multipart builder:
// from stdlib::http::multipart
impl Multipart {
pub fn new() -> *Multipart // random 24-char boundary
pub fn with_boundary(boundary: string) -> *Multipart
pub fn add_field(self: *Multipart, name: string, value: string)
pub fn add_file(self: *Multipart, name: string, filename: string,
content_type: string, data: string)
pub fn content_type(self: *Multipart) -> string // "multipart/form-data; boundary=..."
pub fn body(self: *Multipart) -> string
}
body() serializes every part into the RFC 7578 wire format; content_type() returns the matching header (with the boundary) so the receiver knows where parts split. Hand both to the HTTP client:
import stdlib::http::multipart::*;
import stdlib::http::client::*;
fn main() -> i32 {
let mp: *Multipart = Multipart::new();
mp.add_field("user", "alice");
mp.add_field("note", "hello world");
mp.add_file("avatar", "avatar.png", "image/png", "\x89PNG fake bytes");
let body: string = mp.body();
let ct: string = mp.content_type();
let c: *HttpClient = HttpClient::new();
c.post("https://example.com/upload", body, ct);
return 0;
}
new() picks a random 24-character boundary, which is what you want in production — it is far longer than any plausible part body, so it won't collide with your data. For tests where you assert on the exact bytes, fix the boundary with with_boundary:
import stdlib::http::multipart::*;
fn main() -> i32 {
let mp: *Multipart = Multipart::with_boundary("XYZ");
mp.add_field("k", "v");
let body: string = mp.body();
// body now starts with "--XYZ\r\n..."
println!(body.starts_with("--XYZ\r\n"));
return 0;
}
Parsing uploads with MultipartForm
On the receive side, declare a MultipartForm parameter on a @handler and the framework parses the request body for you — the same FromRequest wiring that Bearer, Headers, and friends use. Two structs carry the result:
// from stdlib::http::multipart
pub struct UploadedFile {
pub name: string, // form field name
pub filename: string, // client-supplied file name
pub content_type: string, // part Content-Type (default application/octet-stream)
pub body: string, // raw file bytes
}
pub struct MultipartForm {
pub fields: *HashMap<string>, // non-file parts, keyed by field name
pub files: *Vector<UploadedFile>, // every file part
}
Plain text parts land in fields; parts with a filename= land in files:
import stdlib::http::*;
import stdlib::http::router::*;
import stdlib::http::handler::*;
import stdlib::http::multipart::*;
@handler
fn upload(mp: MultipartForm) -> HttpResponse {
if mp.files.len() > 0 {
let f: UploadedFile = mp.files.get(0);
println!("got", f.filename, f.content_type, f.body.len());
// f.name -> the form field name
// f.filename -> the client-supplied file name
// f.body -> raw file bytes
}
// Non-file parts land in the fields map.
if mp.fields.contains("user") {
println!("user", mp.fields.get("user"));
}
return HttpResponse::ok().text("uploaded");
}
fn main() -> i32 {
let r: *Router = Router::new();
r.post("/upload", upload);
r.listen(8080);
return 0;
}
If the request isn't a valid upload the extractor short-circuits with a 400 before your handler runs. The error string carries the status prefix the @handler macro consumes:
| Error string | When |
|---|---|
400:not multipart |
Content-Type isn't multipart/form-data |
400:no boundary |
Content-Type is multipart but has no boundary= |
400:malformed |
the body doesn't terminate cleanly |
The Form extractor for urlencoded bodies
For classic HTML form posts (application/x-www-form-urlencoded), use the Form extractor instead. It exposes simple accessors:
// from stdlib::http::extract
impl Form {
pub fn get(self: *Form, name: string) -> string // first value, "" if absent
pub fn has(self: *Form, name: string) -> bool // true iff present
}
import stdlib::http::*;
import stdlib::http::router::*;
import stdlib::http::handler::*;
import stdlib::http::extract::*;
@handler
fn login(form: Form) -> HttpResponse {
let user: string = form.get("user");
if !form.has("pass") {
return HttpResponse::with_status(400).text("missing password");
}
return HttpResponse::ok().text("welcome, ".concat(user));
}
fn main() -> i32 {
let r: *Router = Router::new();
r.post("/login", login);
r.listen(8080);
return 0;
}
get returns the first value for a key (or "" when missing); has tells presence apart from an empty value. A request whose Content-Type isn't application/x-www-form-urlencoded is rejected with 415 before the handler runs. The parsing machinery lives in stdlib::http::form, where FormData is an ordered list of (name, value) pairs — duplicate keys are kept — that you can build, encode to the wire string, and decode back:
import stdlib::http::form::*;
fn build_and_parse() -> !i32 {
let f: *FormData = FormData::new();
f.set("user", "alice");
f.set("token", "abc xyz=99");
let body: string = f.encode(); // "user=alice&token=abc%20xyz%3D99"
let r: !*FormData = form_decode(body);
let data: *FormData = r?;
for let i: i32 = 0; i < data.len(); i++ {
println!(data.name_at(i), "=", data.value_at(i));
}
return ok(0);
}
Compression with gzip_mw
Large text responses (HTML, JSON, JS bundles) compress well. Mount the gzip middleware on the router and it transparently compresses qualifying responses:
import stdlib::http::*;
import stdlib::http::router::*;
import stdlib::http::compress::*;
fn big(req: *HttpRequest) -> HttpResponse {
let mut s: string = "";
for let i: i32 = 0; i < 200; i++ {
s = s.concat("the quick brown fox jumps over the lazy dog\n");
}
return HttpResponse::ok().text(s);
}
fn main() -> i32 {
let r: *Router = Router::new();
r.use_mw(gzip_mw);
r.get("/big", big);
r.listen(8080);
return 0;
}
gzip_mw runs the rest of the chain, then gzips the body and sets Content-Encoding: gzip — but only when it's actually worth it. It skips compression unless all of these hold:
- the request advertised
Accept-Encoding: gzip, - the response status is
2xx, - the response doesn't already carry a
Content-Encoding, - the body is at least 1024 bytes (overhead beats savings on tiny payloads),
- the
Content-Typeisn't an already-compressed format (image/*,video/*,audio/*,application/zip,application/gzip,application/octet-stream).
The HTTP client
So far this chapter has been about serving requests. The other half of the story is making them. Glide ships an HTTP/1.1 client (with transparent HTTP/2-over-TLS when the server negotiates it) in stdlib::http::client. Everything you need lives behind one import:
import stdlib::http::client::*;
Two layers stack on top of each other. At the top are one-shot free functions for the common case — fetch a URL, post some JSON, done. Below them sits HttpClient, a reusable config object you keep around to share defaults (user agent, timeouts, cookies, redirect policy). And at the bottom is HttpClientRequest, an envelope you assemble by hand when you need full control over the method, headers, and body.
Every call returns the same thing: !*HttpResponse. That is a Result wrapping a pointer to the same HttpResponse struct the server side produces, so you read it with the same .status, .body, and .header(...) you already know.
One-shot helpers
For a single request, skip the ceremony. Two free functions build a default client, fire the request, and tear the client down for you:
pub fn http_get(url: string) -> !*HttpResponse
pub fn http_post_json(url: string, body: string) -> !*HttpResponse
http_get does a plain GET. http_post_json POSTs body with Content-Type: application/json — you serialise the JSON yourself (via JsonValue::emit or a string literal), the helper only sets the header.
The return is a Result, so handle the two failure modes. The outer ! is a transport failure — DNS, connection refused, timeout, malformed response. A non-2xx HTTP status is not an error; it arrives as a perfectly good ok response with .status set to whatever the server said. So you check .ok first, then inspect .status:
import stdlib::http::*;
import stdlib::http::client::*;
fn main() -> i32 {
let r: !*HttpResponse = http_get("http://example.com/");
if r.ok {
let resp: *HttpResponse = r.val;
println!("status:", resp.status);
println!(resp.body.len(), "bytes");
} else {
println!("request failed:", r.err);
}
return 0;
}
When you are inside a function that itself returns a Result, the postfix ? operator collapses the transport check into one character — it unwraps r.val on success and early-returns the err otherwise. That leaves you free to branch on the status:
import stdlib::http::*;
import stdlib::http::client::*;
// Propagate transport errors with `?`, then branch on status.
fn fetch_title() -> !string {
let resp: *HttpResponse = http_get("http://example.com/")?;
if resp.status == 200 {
return ok(resp.body);
}
return err(format!("unexpected status {}", resp.status));
}
fn main() -> !i32 {
let body: string = fetch_title()?;
println!("got", body.len(), "bytes");
return ok(0);
}
The HttpClient struct
The one-shot helpers build a fresh client per call, which means no shared cookies, no connection reuse of config, and default settings every time. When you make more than one request — or need a custom user agent, a timeout, or a cookie jar — create one HttpClient and reuse it:
pub fn new() -> *HttpClient
HttpClient::new() returns a raw *HttpClient with sensible defaults; you tune the public fields before issuing requests:
| Field | Type | Default | What it does |
|---|---|---|---|
user_agent |
string |
"glide/0.1" |
Sent as the User-Agent header on every request. |
max_redirects |
i32 |
5 |
How many 3xx hops do/get/post will follow before giving up. |
jar |
*CookieJar |
null |
Assign a CookieJar::new() to auto-ingest Set-Cookie and replay Cookie: across requests. |
tls_insecure |
bool |
false |
Skip TLS cert validation. Local dev only — leave off in production. |
timeout_ms |
i32 |
0 |
Per-request total timeout (connect + send + receive); 0 disables. |
http2 |
bool |
false |
Try HTTP/2 via ALPN over TLS; falls back to HTTP/1.1 transparently. |
The request methods all share the -> !*HttpResponse signature:
| Method | Signature |
|---|---|
get |
get(self, url: string) -> !*HttpResponse |
post |
post(self, url: string, body: string, content_type: string) -> !*HttpResponse |
post_json |
post_json(self, url: string, body: string) -> !*HttpResponse |
put |
put(self, url: string, body: string, content_type: string) -> !*HttpResponse |
delete |
delete(self, url: string) -> !*HttpResponse |
do |
do(self, req: *HttpClientRequest) -> !*HttpResponse |
do_once |
do_once(self, req: *HttpClientRequest) -> !*HttpResponse |
post_json is just post(url, body, "application/json") — a convenience for the most common content type. Here is a configured client posting JSON and reading the response back, headers included:
import stdlib::http::*;
import stdlib::http::client::*;
fn main() -> !i32 {
// Reuse one client across calls; customize its defaults first.
let c: *HttpClient = HttpClient::new();
c.user_agent = "myapp/1.0";
c.timeout_ms = 5000;
c.max_redirects = 3;
let r: !*HttpResponse = c.post_json("http://localhost:8080/users",
"{\"name\":\"alice\"}");
if r.ok {
let resp: *HttpResponse = r.val;
println!("status:", resp.status);
let ct: string = resp.header("Content-Type");
println!("content-type:", ct);
println!(resp.body);
} else {
println!("post failed:", r.err);
}
free(c as *void);
return ok(0);
}
Full control with HttpClientRequest
When the convenience methods do not cover your case — an arbitrary verb like PATCH, several custom headers, or a body you want to attach explicitly — build an HttpClientRequest and hand it to do:
pub struct HttpClientRequest {
pub method: string,
pub url: string,
pub headers: string, // raw "Name: Value\r\n" lines
pub body: string,
}
pub fn new(method: string, url: string) -> *HttpClientRequest
pub fn set(self, name: string, value: string) // append one header line
pub fn body_str(self, s: string) // set body + Content-Length
new starts an envelope with empty headers and body. set appends a header line (no dedup — call it once per header). body_str stores the payload and sets the matching Content-Length for you, so call it after the content-type:
import stdlib::http::*;
import stdlib::http::client::*;
fn main() -> !i32 {
// Full control: build the envelope, set headers, choose the method.
let req: *HttpClientRequest = HttpClientRequest::new("PATCH",
"http://api.local/me");
req.set("Authorization", "Bearer xyz");
req.set("Accept", "application/json");
req.set("Content-Type", "application/json");
req.body_str("{\"name\":\"new\"}");
let c: *HttpClient = HttpClient::new();
let r: !*HttpResponse = c.do(req);
if r.ok {
println!("status:", r.val.status);
}
free(req as *void);
free(c as *void);
return ok(0);
}
do follows redirects up to max_redirects hops: a 303 demotes the method to GET and drops the body, 301/302 do the same for non-GET/HEAD methods, and 307/308 preserve both method and body. Relative Location values are resolved against the current URL. When you must see the 3xx response untouched — writing a proxy or a crawler — use do_once, which sends exactly one request and never follows redirects:
import stdlib::http::*;
import stdlib::http::client::*;
fn main() -> !i32 {
let c: *HttpClient = HttpClient::new();
// get / post / delete / put all return !*HttpResponse.
let g: !*HttpResponse = c.get("http://api.local/items/42");
let p: !*HttpResponse = c.post("http://api.local/upload",
"raw=text",
"application/x-www-form-urlencoded");
let d: !*HttpResponse = c.delete("http://api.local/items/42");
if g.ok { println!("get:", g.val.status); }
if p.ok { println!("post:", p.val.status); }
if d.ok { println!("delete:", d.val.status); }
// do_once never follows redirects — see the 30x exactly as sent.
let req: *HttpClientRequest = HttpClientRequest::new("GET",
"http://api.local/old");
let raw: !*HttpResponse = c.do_once(req);
if raw.ok {
let resp: *HttpResponse = raw.val;
if resp.status == 301 || resp.status == 302 {
println!("redirect to:", resp.header("Location"));
}
}
free(req as *void);
free(c as *void);
return ok(0);
}
Reading the response
Whichever entry point you use, success hands you a *HttpResponse in .val. The fields and methods you care about on the client side are the same ones the server builder produces — status, body, header(name), and headers(name) for repeating headers like Set-Cookie. header lowercases and trims for you, so resp.header("Content-Type") and resp.header("content-type") are equivalent, and a missing header returns "" rather than an error.
HTTP/2, HTTP/3, JWT, reverse proxy, and status codes
The day-to-day HTTP surface — routers, extractors, JSON — sits on top of a small set of lower-level building blocks. This section is a guided tour of that advanced surface: HTTP/2 and HTTP/3 transports, JWT verification, a ready-made reverse proxy, and the status-code helpers. Each lives in its own module; import only what you use.
Status codes
stdlib::http_status is pure Glide — no runtime, no libc. It turns numeric codes into reason phrases and classifies them into the four ranges you branch on most. The signatures are:
pub fn status_text(code: i32) -> string // "OK", "Not Found", ... "Unknown"
pub fn is_ok(code: i32) -> bool // 200..=299
pub fn is_redirect(code: i32) -> bool // 300..=399
pub fn is_client_error(code: i32) -> bool // 400..=499
pub fn is_server_error(code: i32) -> bool // 500..=599
status_text knows the standard IANA registry (1xx through 5xx) and returns "Unknown" for unassigned codes such as 599. The four is_* predicates are simple half-open range checks, which makes them ideal for routing decisions.
| Helper | Range | Example codes |
|---|---|---|
is_ok |
200–299 | 200 OK, 201 Created, 204 No Content |
is_redirect |
300–399 | 301 Moved Permanently, 304 Not Modified |
is_client_error |
400–499 | 400 Bad Request, 404 Not Found, 429 Too Many Requests |
is_server_error |
500–599 | 500 Internal Server Error, 503 Service Unavailable |
Because match in Glide only matches enum variants (never integer literals), classify a code with the predicates and an if/else ladder:
import stdlib::http_status::*;
fn classify(code: i32) -> string {
if is_ok(code) { return "success"; }
if is_redirect(code) { return "redirect"; }
if is_client_error(code) { return "client error"; }
if is_server_error(code) { return "server error"; }
return "informational";
}
fn main() -> i32 {
let codes: *Vector<i32> = Vector::new();
codes.push(200);
codes.push(301);
codes.push(404);
codes.push(503);
for let i: i32 = 0; i < codes.len(); i++ {
let c: i32 = codes.get(i);
println!(format!("{} {}", c, status_text(c)), "->", classify(c));
}
return 0;
}
JWT verification
stdlib::http::jwt verifies HS256 (HMAC-SHA256) JSON Web Tokens. The module exposes one entry point and a claims struct:
pub struct JwtClaims {
pub sub: string,
pub exp: i32,
pub iat: i32,
pub raw: *JsonValue,
}
pub fn jwt_verify(token: string, key: string) -> !JwtClaims
jwt_verify splits the compact token into its three dot-separated parts, base64url-decodes the payload, recomputes the HMAC over header.payload with your shared key, and constant-checks it against the supplied signature. On success you get the decoded claims; raw is the full payload as a *JsonValue so you can read custom claims beyond sub/exp/iat.
The error string is prefixed with the HTTP status you'd return for it, which makes it easy to translate a rejection into a response:
| Error | Meaning |
|---|---|
400:malformed jwt |
Not three parts, bad base64, or payload isn't a JSON object with a string sub |
401:bad signature |
HMAC mismatch — wrong key or tampered token |
401:expired |
exp claim is set and already in the past |
import stdlib::http::jwt::*;
fn main() -> i32 {
let token: string = "eyJ.eyJ.sig";
let r: !JwtClaims = jwt_verify(token, "my-secret-key");
if r.ok {
let claims: JwtClaims = r.val;
println!("subject:", claims.sub);
println!(format!("exp={} iat={}", claims.exp, claims.iat));
} else {
println!("rejected:", r.err);
}
return 0;
}
Reverse proxy
stdlib::http::proxy is a drop-in reverse proxy: it forwards an incoming HttpRequest to an upstream origin and lowers the upstream's response back into your server's HttpResponse. The configuration and entry point are:
pub struct ReverseProxyConfig {
pub upstream: string,
pub strip_prefix: string,
pub preserve_host: bool,
pub rewrite_location: bool,
pub public_base: string,
pub timeout_ms: i32,
pub tls_insecure: bool,
}
impl ReverseProxyConfig {
pub fn to(upstream: string) -> ReverseProxyConfig // conservative defaults
}
pub fn reverse_proxy(cfg: *ReverseProxyConfig, req: *HttpRequest) -> HttpResponse
ReverseProxyConfig::to(upstream) gives you a config with a 30 s timeout and TLS verification on. Tune the fields you need: strip_prefix mounts an app under a local path (/api/users upstream becomes /users), preserve_host keeps the client's Host header, and rewrite_location + public_base rewrite upstream Location redirects to point back at your public origin. Network or parse failures upstream become a 502 Bad Gateway.
import stdlib::http::*;
import stdlib::http::proxy::*;
fn gateway(req: *HttpRequest) -> HttpResponse {
let mut cfg: ReverseProxyConfig = ReverseProxyConfig::to("http://127.0.0.1:9000");
cfg.strip_prefix = "/api";
cfg.rewrite_location = true;
cfg.public_base = "https://edge.example.com";
return reverse_proxy(&cfg, req);
}
fn main() -> !i32 {
http_listen(8080, gateway)?;
return ok(0);
}
For custom pipelines, the module also exposes the lower-level pieces reverse_proxy is built from — proxy_build_upstream_request, proxy_target_url, proxy_filter_request_headers, proxy_filter_response_headers, and proxy_rewrite_location — so you can inspect or adjust the upstream request before it goes out.
HTTP/2
stdlib::http::h2 implements the HTTP/2 framing layer (frames, HPACK, SETTINGS, flow control) on top of a TLS stream. HTTP/2 is almost always negotiated over TLS via ALPN — you ask for h2 in the TLS handshake and the peer agrees. The two ends of the module are a client connection and a server loop:
impl H2Conn {
pub fn over(tls: *TlsStream) -> !*H2Conn // send preface + SETTINGS
pub fn request(self: *H2Conn, method: string, path: string,
authority: string, scheme: string,
extra_headers: *Vector<*HpackHeader>,
body: string) -> !*H2Response
pub fn close(self: *H2Conn)
}
pub struct H2Response { pub status: i32, pub headers: *Vector<*HpackHeader>, pub body: string }
pub fn h2_serve(s: *TlsStream, handler: fn(*HttpRequest) -> HttpResponse)
On the client side: connect with alpn = "h2,http/1.1", check stream.alpn() == "h2", then wrap the stream in an H2Conn and issue requests. H2Conn::over writes the connection preface and your SETTINGS frame; request sends one HEADERS (+ optional DATA) frame and reads the full response back.
import stdlib::http::*;
import stdlib::http::h2::*;
import stdlib::http::hpack::*;
import stdlib::net::tls::*;
fn main() -> i32 {
let cfg: *TlsConfig = TlsConfig::client();
cfg.alpn = "h2,http/1.1";
defer cfg.free();
let ts: !*TlsStream = TlsStream::connect("nghttp2.org", 443, cfg);
if !ts.ok { println!("tls:", ts.err); return 1; }
let stream: *TlsStream = ts.val;
if !stream.alpn().eq("h2") {
println!("server did not negotiate h2");
stream.close();
return 1;
}
let hc: !*H2Conn = H2Conn::over(stream);
if !hc.ok { stream.close(); return 1; }
let h2: *H2Conn = hc.val;
defer h2.close();
let extras: *Vector<*HpackHeader> = Vector::new();
let r: !*H2Response = h2.request("GET", "/", "nghttp2.org", "https", extras, "");
if r.ok {
println!("status:", r.val.status);
println!("body bytes:", r.val.body.len());
}
return 0;
}
On the server side, h2_serve(stream, handler) drives one already-handshaked h2-ALPN connection: it reads the client preface, exchanges SETTINGS, and dispatches each fully-received request to your fn(*HttpRequest) -> HttpResponse handler — the same handler shape the rest of the HTTP module uses. Extra request headers travel as a *Vector<*HpackHeader>, where HpackHeader is just { name, value }.
HTTP/3
stdlib::http::h3 runs HTTP/3 over QUIC (UDP), backed by ngtcp2 + nghttp3. The server entry point mirrors https_listen — same PEM cert/key layout, same handler shape:
pub fn http3_listen(port: i32, cert_path: string, key_path: string,
handler: fn(*HttpRequest) -> HttpResponse) -> !i32
pub fn h3_make_self_signed_cert(cert_path: string, key_path: string,
cn: string, days: i32) -> !i32
http3_listen binds the UDP port and loops forever accepting connections; a single C reader thread owns the socket and demuxes up to 64 simultaneous connections, dispatching each to your handler on its own thread. It returns !i32 so a bind, cert, or key failure surfaces as an err. Use h3_make_self_signed_cert to generate a throwaway cert for local testing.
import stdlib::http::*;
import stdlib::http::h3::*;
fn root(req: *HttpRequest) -> HttpResponse {
return HttpResponse::ok().body("hello over QUIC");
}
fn main() -> !i32 {
h3_make_self_signed_cert("cert.pem", "key.pem", "localhost", 365)?;
http3_listen(443, "cert.pem", "key.pem", root)?;
return ok(0);
}
The module also ships an HTTP/3 client — http3_get(url, tls_insecure) and the fuller http3_request(method, url, extra_headers, ...) — plus an H3SessionCache for persisting QUIC session tickets across runs to enable faster resumption.
Putting it together: a complete JSON API
You have met the router, the typed extractors, Json<T>, middleware and @listen one piece at a time. This section wires them into a single program you can actually run: a small CRUD-style JSON API for a notes resource. It lists notes, fetches one by id, and creates new ones — with the right status codes (200/201/404/422), shared in-memory state, and a logging middleware. The whole thing compiles end to end.
The whole program
import stdlib::http::*;
import stdlib::http::router::*;
import stdlib::http::route::*;
import stdlib::http::typed::*;
import stdlib::http::extract::*;
import stdlib::json::*;
import stdlib::http_status::*;
// ---- the resource ------------------------------------------------------
pub struct Note {
pub id: i32,
pub title: string,
pub done: bool,
}
impl JsonBind for Note {
fn from_json(v: *JsonValue) -> !Note {
let title: string = v.get_string("title")?;
let done: bool = v.opt_bool("done") ?? false;
return ok(Note { id: 0, title: title, done: done });
}
fn to_json(self: *Note) -> *JsonValue {
let o: *JsonValue = JsonValue::object();
o.obj_set("id", JsonValue::int(self.id));
o.obj_set("title", JsonValue::string(self.title));
o.obj_set("done", JsonValue::bool(self.done));
return o;
}
}
// ---- the in-memory store, shared via State<Store> ----------------------
pub struct Store {
pub notes: *Vector<Note>,
pub next_id: i32,
}
impl Store {
pub fn new() -> *Store {
let s: *Store = malloc(sizeof(Store)) as *Store;
s.notes = Vector::new();
s.next_id = 1;
return s;
}
pub fn add(self: *Store, title: string, done: bool) -> Note {
let n: Note = Note { id: self.next_id, title: title, done: done };
self.notes.push(n);
self.next_id = self.next_id + 1;
return n;
}
pub fn find(self: *Store, id: i32) -> ?Note {
for let i: i32 = 0; i < self.notes.len(); i++ {
let n: Note = self.notes.get(i);
if n.id == id { return some(n); }
}
return none();
}
}
// ---- handlers ----------------------------------------------------------
// GET /notes -> 200 with the full list as a JSON array.
@get("/notes")
fn list_notes(state: State<Store>) -> Json<Vector<Note>> {
return Json::wrap(*state.val.notes);
}
// GET /notes/:id -> 200 with the note, or 404 when it's missing.
@get("/notes/:id")
fn get_note(state: State<Store>, id: Path<i32>) -> HttpResponse {
let found: ?Note = state.val.find(id.val);
if !found.has {
return HttpResponse::with_status(404).text("note not found");
}
let n: Note = found.val;
return HttpResponse::ok().json_of(n.to_json());
}
// POST /notes -> 201 with the created note. Body is bound through
// Json<Note> (400 on bad JSON, 422 when from_json rejects it).
@post("/notes")
fn create_note(state: State<Store>, body: Json<Note>) -> HttpResponse {
let created: Note = state.val.add(body.val.title, body.val.done);
return HttpResponse::with_status(201).json_of(created.to_json());
}
// ---- middleware --------------------------------------------------------
// Logs method + path before the handler runs and the status after.
fn logger(req: *HttpRequest, c: *Chain) -> HttpResponse {
println!("->", req.method, req.path_only());
let resp: HttpResponse = chain_next(c);
println!(" <-", resp.status);
return resp;
}
// ---- wire it up --------------------------------------------------------
@listen(8080)
fn main(r: *Router) {
let store: *Store = Store::new();
store.add("buy milk", false);
store.add("write docs", true);
r.state(store as *void);
r.use_mw(logger);
}
The resource and its JSON binding
Note is a plain struct. To move it on and off the wire as JSON it implements JsonBind — the same trait the typed layer relies on. from_json parses an incoming *JsonValue into a Note: get_string("title") returns !string (a required field — propagated with ?), and opt_bool("done") returns ?bool (optional — defaulted with ?? false). Because from_json returns !Note, a missing title makes the whole bind fail, and the @post handler turns that failure into a 422. to_json builds the response value with JsonValue::object() and obj_set.
Shared state with State<T>
Handlers are plain fn(*HttpRequest) -> HttpResponse pointers — there is no closure to capture a database into. The State<T> extractor solves this with a single process-wide slot. You register the pointer once at startup with Router::state; every handler that declares a state: State<Store> parameter then receives it — the @get/@post macro emits State::extract(req) for you and reaches state.val as a *Store. The store is allocated with malloc(sizeof(Store)) as *Store so it is a raw pointer that outlives main's body — exactly what you want for state that lives for the whole server lifetime.
The three handlers and their status codes
Each handler is annotated with a route attribute (@get, @post). The macro renames your function, generates a fn(*HttpRequest) -> HttpResponse wrapper that extracts every typed parameter, and registers the route — so you never write r.get(...) by hand.
| Handler | Route | Success | Failure |
|---|---|---|---|
list_notes |
@get("/notes") |
200 + JSON array |
— |
get_note |
@get("/notes/:id") |
200 + the note |
404 when not found; 400 if :id isn't an int |
create_note |
@post("/notes") |
201 + created note |
400 bad JSON, 422 bind rejected |
list_notes returns Json<Vector<Note>>. The macro auto-converts a Json<T> return into a 200 response via IntoResponse, calling to_json on each element (the blanket JsonBind for Vector<T> you met in the JSON section). Note Json::wrap(*state.val.notes) dereferences the stored *Vector<Note> to wrap it by value. get_note declares id: Path<i32>, so a non-numeric id never reaches your code (the extractor returns 400); when the lookup misses you return an explicit 404. create_note declares body: Json<Note>, and the macro emits the bind ceremony — 400 on malformed JSON, 422 on a failed from_json.
Logging middleware and serving it
logger is an ordinary middleware (fn(*HttpRequest, *Chain) -> HttpResponse): it logs the request, calls chain_next(c) to run the rest of the chain, then logs the response status and returns it. Registered globally in main with r.use_mw(logger), it wraps every route. (If you only wanted it on specific routes, you would stack a @middleware(logger) attribute alongside the route attribute instead.)
@listen(8080) rewrites main into the server bootstrap. It creates the Router, runs your main body with r in scope, registers every @route-family handler in the file, and finishes with r.listen(8080). Because you wrote fn main(r: *Router), your body configures that same router — seeding the store and installing the middleware before the server starts accepting connections.
Run it and exercise the API:
# list (seeded with two notes)
curl localhost:8080/notes
# -> [{"id":1,"title":"buy milk","done":false},{"id":2,"title":"write docs","done":true}]
# fetch one
curl localhost:8080/notes/1 # 200 {"id":1,...}
curl -i localhost:8080/notes/99 # 404 note not found
# create one
curl -i -X POST localhost:8080/notes -d '{"title":"ship it"}' # 201 {"id":3,...}
curl -i -X POST localhost:8080/notes -d '{"done":true}' # 422 (no title)
curl -i -X POST localhost:8080/notes -d 'not json' # 400
That is the full loop: a typed resource, shared state, typed extraction, correct status codes, middleware, and a one-line server. Everything past this point — auth, query params, SSE, static files — slots into the same router the same way.
Recap
Where to next
A running service is only trustworthy if you can prove it works. The next chapter — Testing and benchmarks — covers glide test, the assertion macros, and measuring performance with glide bench (including the routes!(r) + r.dispatch(&req) pattern for testing handlers with no socket in sight).