Routing
PlumeKit routes an HTTP method and a path to a handler. Routing is deliberately simple and allocation-light: a flat route table matched by comparing pre-parsed path segments as UTF-8 bytes, with no regex and no Foundation, so it runs identically on the native server and the Cloudflare Worker.
Registering routes
Register handlers on the Application you build in buildApp(). There is a helper per method, plus on(_:_:_:) for an arbitrary method:
let app = Application()
app.get("/") { _ in .text("home") }
app.post("/posts") { request in … }
app.put("/posts/:id") { request in … }
app.patch("/posts/:id") { request in … }
app.delete("/posts/:id") { request in … }
app.head("/health") { _ in .status(200) }
app.options("/posts") { _ in .status(204) }
app.on(.get, "/legacy") { _ in .text("via on()") }The supported methods are GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS.
Handlers
A handler is a Responder, an async throws closure from a Request to a Response:
public typealias Responder = (Request) async throws -> Responseasync lets a handler await host bindings (a KV read, a SQL query); a synchronous handler is just a closure that never awaits. A thrown error is caught by the framework and returned as a 500, so handlers can try freely:
app.get("/posts/:id") { request in
let db = request.bindings.database
guard let id = Int(request.parameters["id"] ?? "") ,
let post = try await Post.find(id, in: db) else {
return .status(404)
}
return .text(post.title)
}The error is always logged to stdout. Under plumekit serve / plumekit dev (which set PLUMEKIT_ENV=development), the native server renders a dev error page instead of the bare 500: the error's type and description, the request (method, path, query, headers, a body preview), and the app's route table. In production (no env var) it stays a clean 500. The page is native-only by design; the Wasm guest can't stringify errors.
Path parameters
A segment written :name captures that segment. Captured values are read from request.parameters, which returns String?:
app.get("/users/:userID/posts/:postID") { request in
let userID = request.parameters["userID"] ?? ""
let postID = request.parameters["postID"] ?? ""
return .text("user \(userID), post \(postID)")
}Matching is exact per segment. Empty path segments are ignored, so a trailing slash does not change matching (/posts and /posts/ match the same route).
Wildcards (catch-all)
A *name segment (which must be last) captures the rest of the path (one or more segments, slash-joined) into request.parameters["name"]:
app.get("/files/*path") { request in
request.parameters["path"] // "/files/a/b/c.txt" → "a/b/c.txt"
}*name requires at least one trailing segment; name also matches zero, so /assets/path matches the bare /assets too. (Regex patterns are not supported; a regex engine isn't linkable in the Wasm guest. Validate a captured segment in the handler instead.)
Named routes
Hardcoded path strings drift: the route says /posts/:id, a redirect elsewhere builds "/posts/\(id)", and renaming the path breaks one of them silently. A named route declares the template once; you both register the handler and build URLs from the same value:
enum PostRoutes {
static let index = Route("/posts")
static let show = Route1("/posts/:id")
}
app.get(PostRoutes.index) { _ in … }
app.get(PostRoutes.show) { request in … }
return .redirect(to: PostRoutes.show.path(post.id)) // "/posts/42"Route takes no path parameters, Route1 exactly one, Route2 exactly two. The parameter count is part of the type, so a missing or extra value in .path(…) is a compile error, not a broken URL. generate resource scaffolds a <Name>Routes enum and uses it in its redirects.
Route model binding
The show/update/destroy preamble (read :id, parse it, find) collapses into one guard with find(request) on any model:
app.get(PostRoutes.show) { request in
guard let post = try await Post.find(request) else { return .status(404) }
return .text(post.title)
}It reads request.parameters["id"] and parses the integer key; pass parameter: "post_id" for nested routes. Like every ORM lookup, it respects the model's default scope, so a soft-deleted row is not found. See the ORM.
Query parameters
The raw query string is available as request.query, and parsed as request.queryParams (a FormParams, with %XX and + decoding):
app.get("/search") { request in
let term = request.queryParams["q"] ?? ""
let page = request.queryParams.int("page") ?? 1
return .text("q=\(term) page=\(page)")
}Match outcomes
Routing distinguishes three cases:
- Found: the method and path match a route; its handler runs with the captured parameters populated.
- Method not allowed: the path matches a registered route but no route matches this method → 405 Method Not Allowed.
- Not found: no route matches the path → 404 Not Found.
The request
A handler receives an immutable Request value (the router populates its parameters before dispatch):
| Property | Type | Notes |
|---|---|---|
method | HTTPMethod | .get, .post, … ; method.name is "GET", etc. |
path | String | Path only, no query string |
query | String | Raw query string (without the ?) |
headers | Headers | Case-insensitive, order-preserving |
body | [UInt8] | Raw request body |
bodyText | String | Body decoded as UTF-8 |
parameters | Parameters | Path parameters; request.parameters["id"] → String? |
queryParams | FormParams | Parsed query string |
context | Context | Per-request host capabilities and logging |
bindings | Bindings | Typed, non-optional view of declared capabilities |
principal | Principal? | The authenticated identity, if any |
Headers are read case-insensitively:
let contentType = request.headers.first("content-type")
let accepts = request.headers.all("accept")Bindings are reached through request.bindings (typed, non-optional, generated from the capabilities you declare in plumekit.toml) or request.context (optional). See Bindings & drivers.
The response
Build responses with Response's convenience constructors, or the initialiser for full control:
.text("hello") // text/plain; charset=utf-8
.text("nope", status: 404)
.html("<h1>hi</h1>") // text/html; charset=utf-8
.html(bytes: renderedHTML) // pre-rendered UTF-8 bytes (e.g. Plume)
.json("{\"ok\":true}") // pre-serialised JSON string
.json(.object([("ok", .bool(true))])) // from a JSONValue
.status(204) // bare status, empty body
.redirect(to: "/posts") // 303 See Other by default
Response(status: 201, headers: headers, body: bytes) // full controlResponse exposes status, headers, body ([UInt8]), bodyText and reasonPhrase. Set headers before returning:
var response = Response.text("created", status: 201)
response.headers.set("location", "/posts/42")
return responseStreaming bodies
A response can produce its body incrementally instead of buffering it, for exports and generated downloads of unknown size:
app.get("/posts.csv") { _ in
.stream(contentType: "text/csv") { writer in
try await writer.write("id,title\n")
for post in try await Post.all() {
try await writer.write("\(post.id),\(post.title)\n")
}
}
}The native server sends each written chunk to the client as it comes (chunked transfer encoding). On Cloudflare and Lambda, whose response is a single payload, the producer runs to completion and the result is sent whole; the handler code is identical.
The other direction is per route: body: .streaming delivers the request body in chunks through request.bodyReader instead of buffering it, so a large upload never sits in memory (and the native server's 32 MB buffered-body cap doesn't apply):
app.post("/import", body: .streaming) { request in
var total = 0
while let chunk = try await request.bodyReader?.next() {
total += chunk.count // process incrementally: write to storage, parse, hash…
}
return .text("received \(total) bytes")
}On a streaming route request.body is empty, so request.form (and the CSRF form field) see nothing: authenticate these endpoints with a bearer token or send the CSRF token as a header. On buffered targets the handler receives the body as one replayed chunk.
Flash messages
A flash is a one-time notice carried across a redirect: "Post created" on the page you land on, shown exactly once:
return .redirect(to: "/posts").flash("Post created") // Flash.notice
return .redirect(to: "/posts").flash("Payment failed", kind: Flash.error)Kinds: Flash.notice, .success, .error, .warning. The kind doubles as a CSS class for the banner. The next handler reads it with request.flash?.message / ?.kind and passes it into the view; the framework clears the cookie automatically after the page that shows it, so the message appears exactly once. It rides a short-lived (60-second) plumekit_flash cookie with no server-side storage, so it works identically on every target. The content is client-visible display text: never put secrets in it. generate resource scaffolds the full loop (created/updated/deleted flashes plus a banner in the Index view).
Resource controllers
For the conventional RESTful actions of a resource, group them in a Controller and wire the routes in one call with app.resources(_:_:):
app.resources("/api/posts", PostController())
// GET /api/posts → index
// POST /api/posts → create
// GET /api/posts/:id → show
// PUT /api/posts/:id → update
// PATCH /api/posts/:id → update
// DELETE /api/posts/:id → destroyUnimplemented actions fall back to 405. See Controllers for the full protocol.
Groups and scoped middleware
app.group(_:middleware:_:) registers a set of routes that share a path prefix and/or middleware. Group middleware runs after the global stack and only for routes in the group, so it's how you apply middleware to specific routes:
app.group("/admin", middleware: [requireAdmin]) { admin in
admin.get("/users") { ... } // GET /admin/users, behind requireAdmin
admin.resources("posts", PostController()) // all behind requireAdmin
}Groups nest; prefixes compose and middleware accumulates:
app.group("/api", middleware: [rateLimit]) { api in
api.group("/v1") { v1 in
v1.resources("posts", PostController()) // /api/v1/posts, rate-limited
}
}Global middleware registered with app.use(...) still runs for every request; see middleware.
Translations
The localization middleware resolves the request's language and gives handlers and views a t("key") function. See Translations.