Routing ​
The httpx.zig server provides a fast framework-grade router: patterns compile once at registration (segments, converters, specificity), and matching borrows path slices with no per-request parsing. One matcher serves HTTP, WebSocket, and SSE routes.
Basic Routing ​
Routes are registered on the Server with verb helpers, or on a Router with full options:
try server.get("/", indexHandler);
try server.post("/users", createUserHandler);
try server.router.get("/users/{id:int}", getUserHandler, .{
.name = "user",
});Canonical syntax ​
One grammar — {...} only (no :id legacy form):
/users // static
/users/{id} // generic parameter (one segment)
/users/{id:int} // typed parameter
/blog/{slug:slug} // slug
/files/{path:path} // catch-all (remaining segments, must be last)Converters: str (default) | string | int | uint | float | bool | uuid | slug | path. Unknown converters, empty names, duplicate names, and misplaced catch-alls fail at registration.
Typed parameters ​
Converters validate at match time, so more specific routes win deterministically and handlers get real types:
fn getUser(ctx: *httpx.Context) anyerror!httpx.Response {
const id = ctx.paramInt("id") orelse return ctx.textStatus(400, "bad id");
// ...use id: i64...
}
// Optional struct binding (Level 2) — plain param() stays simple:
const Params = struct { userId: u64, postId: u64 };
const p = try ctx.bindParams(Params); // MissingParam / InvalidParamValueparamInt, paramUint, paramFloat, paramBool are available; uuid/slug validate and return the borrowed string. Optional struct fields (?u64, defaulted fields) tolerate missing parameters.
Slugs and catch-all paths ​
try server.router.get("/blog/{slug:slug}", postHandler, .{});
// matches /blog/hello-world, rejects /blog/Hello-World, /blog/-lead
try server.router.get("/files/{path:path}", fileHandler, .{});
// matches /files/a.txt and /files/docs/api/v1/index.htmlNested routes and groups ​
const api = server.router.group("/api", .{});
const users = api.group("/users", .{});
try users.get("/", listUsers, .{});
try users.get("/{id:int}", getUser, .{});
// → /api/users and /api/users/{id:int}; parent params stay visible.Groups share a prefix (and optional middleware) without string concatenation. router.mount("/admin", &adminRouter, .{}) merges another router's methods, names, metadata, and parameters.
Named routes and reversing ​
try server.router.get("/users/{id:int}", getUser, .{ .name = "user" });
const url = try server.router.url("user", .{ .id = 42 }); // "/users/42"
defer allocator.free(url);Missing, unknown, or invalid values are errors — never silent bad URLs.
Conflicts and priority ​
/users/{id}vs/users/{name}→ registration error (DuplicateRoute)./users/me(static) beats/users/{id:int}(typed) beats/users/{name}(generic) beats/files/*rest(wildcard).- Equal-specificity ties keep the first registered route (documented; prefer unambiguous patterns).
404 vs 405 ​
A path matching no route returns 404. A path matching another method returns 405 with an Allow header (e.g. Allow: GET, POST). OPTIONS without an explicit route returns 204 + Allow. HEAD falls back to the GET handler (transports strip the body).
Middleware ​
Order is deterministic: router middleware → route middleware → handler.
try server.use(loggingMw); // router-wide
try server.router.get("/m", handler, .{ .middleware = &.{authMw} }); // route-levelQuery parameters ​
Path and query stay separate: /users/42?page=2 gives param("id") plus queryParam("page"). Never put query strings in patterns.
Trailing slashes and normalization ​
Matching is lenient: /users and /users/ (even //users//) match the same pattern. Query strings and fragments are stripped before matching.
OpenAPI ​
Typed converters feed OpenAPI generation automatically: /users/{id:int} emits {id, in: path, required: true, schema: {type: integer}} unless meta.params documents the parameter explicitly.
WebSocket and SSE ​
WebSocket (/ws/{roomId}) and SSE endpoints are plain router routes — no separate routing engine exists.
