Introduction
zapline is a web framework for Rust that brings three things together:
- FastAPI-level ergonomics. You write plain
async fns with typed arguments, and you get request parsing, validation, errors, and interactive OpenAPI docs automatically. - Enterprise-grade structure. It includes dependency injection, layered configuration, lifecycle hooks, plugins, guards and policies, observability, and health checks. These are the building blocks large teams expect from Spring Boot, NestJS or ASP.NET Core.
- Top-tier performance. Thread-per-core runtimes, zero-allocation routing, and no per-request cost for features you don't use. On equal hardware, zapline outperforms axum and actix-web in its own benchmark suite.
use zapline::prelude::*; #[get("/hello/{name}")] async fn hello(Path(name): Path<String>) -> String { format!("Hello, {name}!") } fn main() -> std::io::Result<()> { App::new().mount(hello).run("0.0.0.0:8080") }
Philosophy
- The common case is one line. Each feature has a default that works out of the box:
App::openapi(...),.plugin(Health::new()),.wrap(Cors::permissive()). - Everything is overridable. Defaults are built from public traits and builders, so you can swap any piece.
- Pay only for what you use. Optional features sit behind Cargo features and extension points. An app that doesn't use metrics runs exactly the same code path as if metrics didn't exist.
- Testable by default.
app.test()runs the whole pipeline in-process with no sockets.
How this guide is organized
- Getting started gets an app running and explains the CLI.
- Core concepts covers the request pipeline: routing, extractors, responses, errors and middleware.
- Building APIs, Security, Realtime & content and Operations cover the batteries.
- The Reference lists every Cargo feature and explains how the framework is put together.
Quickstart
On Windows, install the Visual Studio C++ Build Tools first and use PowerShell. See Platform support.
With the CLI (recommended)
cargo install --path crates/zapline-cli # installs `zapline` and `cargo zapline`
zapline new my-api # templates: minimal | api (default) | full
cd my-api
zapline dev # build, run, live-reload the browser on every change
Open http://localhost:8080/docs for the interactive API docs, and run cargo test for the generated tests.
Or skip the project entirely: zapline new hello.rs && zapline dev hello.rs gives you the same in a
single file, dependencies declared at its top (One-file apps).
By hand
[dependencies]
zapline = "0.1"
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
use zapline::prelude::*; // `#[model]` derives Debug, Clone, Serialize, Deserialize, JsonSchema and Validate. #[model] struct Todo { #[validate(length(min = 1, max = 200))] title: String, done: bool, } /// Create a todo. #[post("/todos", tag = "todos", status = 201)] async fn create(Valid(Json(todo)): Valid<Json<Todo>>) -> (StatusCode, Json<Todo>) { (StatusCode::CREATED, Json(todo)) } /// Say hello. #[get("/hello/{name}")] async fn hello(Path(name): Path<String>) -> String { format!("Hello, {name}!") } pub fn app() -> App { App::new() .mount((create, hello)) .openapi(OpenApi::new("Todos", "0.1.0")) } fn main() -> std::io::Result<()> { app().run("0.0.0.0:8080") } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn rejects_empty_titles() { let res = app().test().post("/todos").json(&serde_json::json!({"title": "", "done": false})).send().await; assert_eq!(res.status(), 422); } }
What you get:
POST /todosparses and validates the body. Invalid input returns 422 with per-fielddetails./openapi.jsonand Swagger UI at/docsare generated from the signatures and doc comments.- A thread-per-core server with graceful shutdown on SIGINT/SIGTERM.
Next, read Routing and Extractors, or browse the runnable examples with cargo run -p zapline-examples --example <name>.
One-file apps
A whole zapline app can live in a single .rs file, with no Cargo.toml, no
src/ and no project directory. It is good for prototypes, internal tools,
webhooks, demos, bug reports and code an AI assistant hands you in one piece.
zapline new hello.rs # write a starter: routes, a validated JSON model, OpenAPI docs, tests
zapline dev hello.rs # serve on http://localhost:8080, rebuild and live-reload on every save
zapline run hello.rs # build (cached) and run it
./hello.rs # the same, through its shebang line
zapline test hello.rs # run the #[cfg(test)] tests inside the file
The first build compiles zapline once. After that, every one-file app on the machine shares those compiled dependencies, and running an unchanged file starts it right away: cargo is not invoked at all.
The file
#!/usr/bin/env -S zapline run //! Hands out ids. //! //! ```cargo //! [dependencies] //! uuid = { version = "1", features = ["v4"] } //! ``` use zapline::prelude::*; #[get("/ids/new")] async fn new_id() -> String { uuid::Uuid::new_v4().to_string() } fn main() -> std::io::Result<()> { App::new().mount(new_id).run("0.0.0.0:8080") }
- The shebang line is optional. With it (and
chmod +x), the file runs as./app.rs; everything after the file name goes to the app. - Dependencies go in a
cargoblock inside the leading//!comment, in Cargo.toml syntax. The block is optional too. - The rest is ordinary Rust. rustc ignores the shebang, and the header is a doc comment, so the file is still valid Rust that rustfmt and clippy accept.
Dependencies
zapline, serde, serde_json and tokio are always available, so most apps need no header at all:
| Crate | Default |
|---|---|
zapline | this CLI's release series (e.g. "0.1"), default features |
serde | { version = "1", features = ["derive"] } |
serde_json | "1" |
tokio | { version = "1", features = ["full"] } |
List one of them only to change it. An entry without a version, path or git
source keeps the default version, so zapline = { features = ["ws", "sqlite"] }
just adds features. Any other crate is added the usual way: uuid = "1".
A header may contain these tables: [package] (only edition,
rust-version, version, description, authors, license),
[dependencies], [dev-dependencies], [target.'cfg(...)'.dependencies],
[features], [profile.*], [patch.*] and [lints]. The package name comes
from the file name, and the file itself is the only target, so [[bin]],
[lib], [workspace] and build scripts are not allowed. The edition defaults
to 2021.
Relative path = "..." dependencies are relative to the file, not to the
hidden project, so a script next to a zapline checkout can use
zapline = { path = "zapline/crates/zapline" }. zapline new hello.rs --zapline-path <checkout> writes that line for you.
Commands
| Command | What it does |
|---|---|
zapline new <name>.rs [--path DIR] | Write a starter (refuses to overwrite). On unix it is made executable |
zapline run [--release] <file.rs> [args] | Build if needed, then run it in the current directory with args (a -- before them is optional). Exits with the app's exit code |
zapline dev <file.rs> [-- args] | Watch the file, rebuild after each save and swap in the new version with a browser reload, like zapline dev in a project (see The dev loop). Header changes take effect on save. A broken header or build keeps the previous version running |
zapline test <file.rs> [filter] | Run the tests in the file and print only the failures and a summary |
zapline check [--clippy] <file.rs> | One line per error or warning |
zapline routes [--json] <file.rs> | Print the route table |
zapline run --print-manifest <file.rs> | Write the hidden project and print the path of its Cargo.toml |
Options for zapline run come before the file; anything after the file belongs
to the app, so ./app.rs --release passes --release to the app.
Errors point at your file
A mistake in the header is reported at its line and column, like a compiler error, and nothing is built:
error: app.rs:7:15: invalid ```cargo block: invalid basic string, expected `"`
Compiler errors and warnings name the real file and line too, because the
generated project's binary target is your file itself, not a copy.
include_str!("data.txt") and #[path] modules resolve next to the file.
How it works
zapline run app.rs turns the file into a cargo project in the user cache
directory and builds that:
~/.cache/zapline/ ($XDG_CACHE_HOME/zapline; %LOCALAPPDATA%\zapline on Windows)
├── scripts/<hash of the file's path>/
│ ├── Cargo.toml [[bin]] path = "/abs/path/app.rs", deps from the header
│ ├── Cargo.lock kept between runs: versions stay put until you change the header
│ └── .cargo/config.toml build.target-dir = the shared target below
└── target/ one target directory for every one-file app
- One project per file path. Edit the file and the same project is reused;
move it and it gets a new one. The binary is named after the file plus a
short hash, so
api.rsfiles in different directories never overwrite each other in the shared target directory. - Shared target directory. Dependencies are compiled once for all scripts
(per version and feature set, as cargo does).
CARGO_TARGET_DIRstill wins when set. - No cargo for an unchanged file. After a build, zapline records the
executable together with a hash of the generated manifest. On the next run it
compares the executable's age with every source file in the dep-info that
cargo writes next to it (the script, files it includes, and the sources of
path dependencies) and with the manifests of path dependencies. If nothing is
newer, it starts the executable directly (a few milliseconds); otherwise it
runs
cargo build, which recompiles only what changed. - On unix,
zapline runreplaces itself with the app (exec), so signals, the exit code and stdin are the app's own.
Delete ~/.cache/zapline at any time to reclaim the space; it is rebuilt on
demand.
Editor support
rust-analyzer needs a Cargo.toml. Point it at the hidden one:
zapline run --print-manifest app.rs
# ~/.cache/zapline/scripts/1f2e3d4c5b6a7980/Cargo.toml
and add that path to rust-analyzer.linkedProjects in your editor settings
(VS Code: "rust-analyzer.linkedProjects": ["/home/you/.cache/zapline/scripts/.../Cargo.toml"]).
Growing into a project
When the file gets big, create a project with zapline new my-app and move the
code over: handlers and models go to src/lib.rs (or src/routes/), main
to src/main.rs, the header's dependencies to Cargo.toml, and the tests to
tests/. Everything else (the API, the test client, zapline dev) works the
same.
Cheatsheet
One runnable file showing the canonical way to do each common task: models with validation, handlers, errors, dependency injection, middleware, route groups, OpenAPI, health checks and in-process tests. When there are several ways to do something, this is the one the guide, the CLI generators and AGENTS.md use.
//! The zapline cheatsheet: one file, the canonical way to do each common
//! task. It is included verbatim in the guide (`docs/src/cheatsheet.md`) and
//! in `llms.txt`, so it doubles as compact context for AI assistants.
//!
//! cargo run -p zapline-examples --example cheatsheet
//! cargo test -p zapline-examples --example cheatsheet
use std::collections::BTreeMap;
use std::sync::{Arc, RwLock};
use zapline::middleware::{Cors, SetRequestId};
use zapline::prelude::*;
// ---- Models: `#[model]` derives serde, JsonSchema (OpenAPI) and, with `#[validate]` rules, Validate.
// Doc comments become OpenAPI descriptions.
/// A note.
#[model]
pub struct Note {
pub id: u64,
pub text: String,
}
/// Body of `POST /notes`.
#[model]
pub struct NewNote {
#[validate(length(min = 1, max = 500))]
pub text: String,
}
/// Query string of `GET /notes`.
#[model]
pub struct Page {
pub limit: Option<usize>,
}
// ---- Services: a trait behind `Dep<dyn Trait>` so tests can swap it.
pub trait Notes: Send + Sync {
fn list(&self, limit: usize) -> Vec<Note>;
fn get(&self, id: u64) -> Option<Note>;
fn add(&self, text: String) -> Note;
}
#[derive(Default)]
pub struct MemNotes(RwLock<BTreeMap<u64, Note>>);
impl Notes for MemNotes {
fn list(&self, limit: usize) -> Vec<Note> {
self.0
.read()
.unwrap()
.values()
.take(limit)
.cloned()
.collect()
}
fn get(&self, id: u64) -> Option<Note> {
self.0.read().unwrap().get(&id).cloned()
}
fn add(&self, text: String) -> Note {
let mut map = self.0.write().unwrap();
let note = Note {
id: map.len() as u64 + 1,
text,
};
map.insert(note.id, note.clone());
note
}
}
// ---- Handlers: route macro + async fn; arguments are extractors.
/// List notes.
#[get("/notes", tag = "notes")]
async fn list_notes(notes: Dep<dyn Notes>, Query(page): Query<Page>) -> Json<Vec<Note>> {
Json(notes.list(page.limit.unwrap_or(50).min(200)))
}
/// Fetch one note. Unknown ids are a 404 with a stable error code.
#[get("/notes/{id}", tag = "notes")]
async fn get_note(notes: Dep<dyn Notes>, Path(id): Path<u64>) -> Result<Json<Note>> {
let note = notes
.get(id)
.ok_or_else(|| Error::not_found().with_code("note_not_found"))?;
Ok(Json(note))
}
/// Create a note. Invalid bodies get a 422 with per-field details.
#[post("/notes", tag = "notes", status = 201)]
async fn create_note(
notes: Dep<dyn Notes>,
Valid(Json(new)): Valid<Json<NewNote>>,
) -> (StatusCode, Json<Note>) {
(StatusCode::CREATED, Json(notes.add(new.text)))
}
// ---- Middleware: a plain async fn.
async fn powered_by(req: Request, next: Next) -> Response {
let mut res = next.run(req).await;
res.headers_mut()
.insert("x-powered-by", HeaderValue::from_static("zapline"));
res
}
// ---- The app: one function that tests and `main` share.
pub fn app(notes: Arc<dyn Notes>) -> App {
App::new()
.provide_dyn::<dyn Notes>(notes)
.wrap(SetRequestId::new())
.wrap(Cors::permissive())
.wrap(powered_by)
.nest(
"/api",
Routes::new().mount((list_notes, get_note, create_note)),
)
.plugin(Health::new()) // /health, /health/live, /health/ready
.openapi(OpenApi::new("Notes", "1.0.0")) // /openapi.json + /docs
}
fn main() -> std::io::Result<()> {
app(Arc::new(MemNotes::default())).run("0.0.0.0:8080")
}
// ---- Tests: in-process, no sockets.
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[tokio::test]
async fn create_then_fetch() {
let client = app(Arc::new(MemNotes::default())).test();
let res = client
.post("/api/notes")
.json(&json!({"text": "hi"}))
.send()
.await;
assert_eq!(res.status(), 201);
let note: Note = res.json();
let res = client.get(&format!("/api/notes/{}", note.id)).send().await;
assert_eq!(res.status(), 200);
assert_eq!(res.header("x-powered-by"), Some("zapline"));
}
#[tokio::test]
async fn validation_and_not_found() {
let client = app(Arc::new(MemNotes::default())).test();
let res = client
.post("/api/notes")
.json(&json!({"text": ""}))
.send()
.await;
assert_eq!(res.status(), 422);
let res = client.get("/api/notes/99").send().await;
assert_eq!(res.status(), 404);
}
}
Viewing this on GitHub? The code block above only renders on the docs site; the file itself is examples/examples/cheatsheet.rs.
Run it with cargo run -p zapline-examples --example cheatsheet and open http://localhost:8080/docs.
Everything else in one line each
| Task | How |
|---|---|
| Typed path params | Path(id): Path<u64>, Path((org, repo)): Path<(String, String)> |
| Query string / form | Query(q): Query<T>, Form(f): Form<T> |
| Validated input | Valid(Json(body)): Valid<Json<T>> (also Valid<Query<T>>, Valid<Form<T>>) |
| Plain shared state | App::state(value) + State(v): State<T> |
| Services / DI | App::provide(value) or provide_dyn::<dyn Trait>(arc) + Dep<T> |
| Errors | Error::bad_request(msg), not_found(), conflict(msg), unauthorized(), forbidden(), .with_code("code") |
| Domain errors | impl From<MyError> for zapline::Error, then ? |
| HTML / redirect | Html(string), Redirect::to("/path") |
| Route group + prefix | App::nest("/api", Routes::new().mount((a, b))) |
| Group middleware / auth | Routes::wrap(mw), .require_roles(["admin"]), .wrap(Authenticate::jwt()) |
| Config | Config::load()? reads config/default.toml, config/{profile}.toml, APP__* env and .env |
| Startup / shutdown | App::on_startup(|res| async move { res.insert(pool); Ok(()) }) (then State<Pool>), App::on_shutdown(..) |
| Background work | BackgroundTasks extractor, App::schedule(Schedule::every(dur), job), Schedule::cron("0 9 * * MON-FRI"); once per cluster: .jobs(|q| q.cron("@daily", MyJob {})) |
| Live updates | App::live(Live::new()) + live: Live; live.publish("topic", "event", html); handler returns live.sse("topic").into_response(); htmx: hx-ext="sse" sse-connect="/live/topic" sse-swap="event" (Live updates) |
| Optional features | zapline add ws redis turns them on in Cargo.toml; zapline add --list shows them all. A missing one shows up in zapline check as hint: enable the `ws` feature: zapline add ws (Feature flags) |
| Realtime | WebSocketUpgrade (ws feature), Sse |
| One binary + deploy | zapline::embed!("templates") wherever a dir goes (disk in debug); zapline ship user@host (Deployment) |
| Static files | App::assets("/static", "static") (fs feature): hashed URLs cached forever, {{ asset('app.css') }} in templates, assets: Assets + assets.url("app.css") in handlers; plain ServeDir::new("public") for anything else |
| Database | App::database(Database::from_env()) + db: Db, then sqlx::query_as(..).fetch_one(&*db) (Databases) |
| List endpoints | params: ListParams + Listing::new("posts", "id, title").sortable(["title"]).search(["title"]).filter_bool("done").fetch(&db, ¶ms).await? returns Paged<T> (?limit=&offset=&sort=-id&q=&done=true) |
| Migrations | zapline db new create_posts (up + down SQL), applied at startup or by db migrate; db status/rollback/redo/reset |
| Test data / seeds | Factory::new("posts").set("title", "Hi").create(&pool).await? fills the other required columns (and parent rows) from the schema; .create_many(&pool, 10); record .int("id"), .json(), .parse::<Post>(); App::seed(|db| async move { .. }) + zapline db seed |
| HTML + htmx | App::views("templates") + views: Views, hx: HxRequest, then views.page_or_block(&hx, "page.html", "block", context!{..}) (Views) |
| Admin panel | App::admin(|admin| admin): every table at /admin (debug + localhost); production: .protect(middleware); .exclude([..]), .hide(["users.email"]), .read_only() (Admin panel) |
| Translations | App::i18n(I18n::load("locales")?) (locales/en.toml: hello = "Hi, {name}!") + t: Locale, then t.t_with("hello", &[("name", &n)]), t.plural("items", n, &[]); templates: context! { t }, {{ t("hello", name=n) }}; language from ?lang=, cookie, Accept-Language (Translations) |
| Several instances | let redis = Redis::from_env()? (REDIS_URL); Cache::redis(redis.clone()), Live::new().redis(..), RateLimit::per_ip(..).redis(..), SessionLayer::new(RedisStore::new(..)); redis.cmd(["INCR", "k"]).await?.int(); TLS: feature redis-tls, rediss://...?ca_file=ca.pem&sni=name (Several instances, over TLS) |
| OpenTelemetry | feature otlp, then OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 (or Telemetry::new().otlp(url)); requests become spans, traceparent is continued (Observability) |
| Caching | App::cache(Cache::memory()) + cache: Cache, then cache.get_or_set("key", ttl, || async { Ok(v) }); pages: Routes::wrap(ResponseCache::new(ttl)); App::wrap(ETag) (Caching) |
| Files | App::storage(Storage::from_env()?) + storage: Storage: put, put_with(k, b, PutOptions), get, get_stream/put_stream, list(prefix), metadata, copy, delete, url(key, ttl), upload_url (browser to S3) (File storage) |
mail.send(Email::to(a).cc(b).bcc(c).attach(name, type, bytes)); .template(&views, "mail/x", ctx); /_dev/mail; Mailer::outbox().sent() (Email) | |
| Dev loop | zapline dev: templates, translations and static files reload without a restart, CSS swaps in place, Rust changes swap with no downtime, compile errors show in the browser (The dev loop) |
| Dev console | App::dev_console(): routes, requests, errors (with ZL codes and fixes), jobs, mail at /_dev (debug, localhost only) |
| Typed frontend | a frontend/ dir: zapline dev rewrites frontend/src/api.ts after each build (api.createTask(body), isValidationError(e, "createTask") && e.fields.title); no build step: OpenApi::new(..).client_js() + import { api } from "/_zapline/api.js" (Typed frontends) |
| Metrics | .plugin(Metrics::new()) serves Prometheus /metrics |
| Tests | app().test().get("/x").bearer(token).send().await then .status(), .json::<T>(), .text() |
| Fuzz every route | app().test().fuzz().await.assert_clean() or zapline fuzz |
| Tests from traffic | zapline dev --record, use the app, zapline snap signup; replayed by app().test().replay_snapshots("tests/snapshots") |
| CLI | zapline new <app> --template fullstack, dev, generate resource post title body:text due:date author:references (migration with timestamps, JSON API with PATCH, index/show/edit pages, tests), g migration add_x_to_posts x:int=0 (.up.sql + .down.sql, and the posts resource's structs, queries, pages and test follow), g auth (accounts, password reset, GitHub/Google login), g job <name> (durable job), g mailer <name> (templated email + test), g tailwind + css (Tailwind, no Node), g deploy fly|railway|render|compose, db new/migrate/status/rollback/redo/reset, routes --json, openapi, fuzz, client (typed TS/JS client; dev keeps frontend/src/api.ts current), snap <name>, check, test, explain ZL0002, mcp (MCP server for AI assistants) |
| HTML forms | #[serde(default, deserialize_with = "zapline::extract::empty_as_none")] for optional inputs, zapline::extract::checkbox for checkboxes |
| Form errors + flash | ValidForm(form): ValidForm<T>; Err(invalid) => return views.render_invalid("new.html", context!{}, invalid) (422 with {{ errors.x }}, {{ old.x }}); flash: Flash, flash.success("Saved"); Ok(flash.redirect("/posts")), {% for m in flash %} in templates (Views) |
Project layout & the CLI
The zapline CLI scaffolds projects, generates code, runs a live-reloading dev server, and talks to your app to print its routes or export its OpenAPI document. Install it with cargo install zapline-cli. You can call it as zapline <cmd> or cargo zapline <cmd>.
| Command | What it does |
|---|---|
zapline new <name>.rs / zapline run <file.rs> | A one-file app: a whole app in a single .rs file with no Cargo.toml. dev, test, check and routes take the file too |
zapline new <name> [-t minimal|api|full|fullstack] [--path DIR] | Create a project. api (the default) includes routes, models, validation, OpenAPI, Docker and CI. full adds layered config, DI, health and metrics probes, tracing and a hardened middleware stack. fullstack is server-rendered HTML with htmx, SQLite (zero setup) with migrations, database sessions with flash messages, and a JSON API. |
zapline generate resource <name> [field:type ...] (g resource) | Add a resource and mount it automatically. In a project with migrations/: a SQL migration with created_at/updated_at, a sqlx JSON API (list, get, create, PUT, PATCH, delete), htmx index, show and edit pages (when templates/base.html exists; rejected posts come back with a message per field, saves set a flash message) and tests. Otherwise: CRUD handlers over an in-memory store. Types: string, text, int, float, bool, date, datetime, uuid, references; ? for optional (details). |
zapline g migration <name> [field:type[=default] ...] | A reversible migration (.up.sql + .down.sql) from its name: add_<cols>_to_<table> adds columns, remove_<cols>_from_<table> drops them, create_<table> creates a table. Adding or removing columns of a g resource table updates its module, pages and test too (details) |
zapline db new <name> [--reversible|--one-way] / zapline db migrate | Create a SQL migration (an .up.sql + .down.sql pair) / apply pending ones (Databases) |
zapline db status | Every migration: version, name, when it was applied, whether its file changed since, and whether it has a down file. Fails, naming the file to restore, when an applied migration was edited or deleted |
zapline db rollback [--steps N] / zapline db redo | Revert the last N migrations with their .down.sql files / revert the last one and apply it again |
zapline db reset | Revert every migration, apply them all and insert the seed data. Development only: rollback, redo and reset refuse --release and APP_ENV=production unless --force (guard rails) |
zapline db seed | Insert the app's seed data (App::seed) after migrating |
zapline g handler <name> --method get --path /x | Add a single route |
zapline g middleware <name> | Add a middleware function |
zapline g job <name> | A durable job: src/jobs/<name>.rs, registered in src/jobs/mod.rs and app(), plus a test |
zapline g mailer <name> | An email from templates: src/mail/<name>.rs (declared in src/mail/mod.rs), templates/mail/<name>.html and .txt, and a test against the outbox mailer; enables the mail and views features |
zapline g auth | Add accounts: users migration, sign-up/log-in/log-out, password reset by email (JSON API and htmx pages), database sessions, a CurrentUser extractor and tests (Accounts) |
zapline g deploy fly|railway|render|compose | Hosting config for the project's Dockerfile, with volumes, databases, health checks and secrets filled in |
zapline add <feature>... / zapline add --list | Turn on zapline features in Cargo.toml (zapline add ws redis), whatever shape the zapline dependency has. The list stays sorted, the rest of the file is untouched, features already on are reported and skipped, and a misspelled name gets a suggestion (wss: did you mean ws?). --list prints every feature with a one-line description and marks the ones the project has |
zapline g tailwind | Tailwind CSS without Node: styles/app.css compiled to static/app.css by the standalone Tailwind binary |
zapline dev [--bin X] [--no-proxy] [-- app args] | The dev loop: template, translation and static file changes reload open pages without a restart, CSS is swapped in place, Rust changes rebuild in the background and swap in the new binary with no downtime, and compile errors show as an overlay in the browser. Sets ZAPLINE_DEV=1, so 500 responses show the real error on the dev error page, and records the app's address in target/zapline-dev.addr for zapline mcp. With Tailwind, also rebuilds static/app.css as you save. |
zapline ship <user@host> [--dry-run] [--static] [--rollback] | Build a release binary, copy it over ssh and swap it in next to the running one on the same port, with systemd. The first run creates the user, /etc/<app>/env (with generated secrets) and the unit. Any failure leaves the old release serving. --env K=V sets a setting, --keep N keeps N releases |
zapline css [--minify|--watch] | Build static/app.css from styles/app.css with Tailwind |
zapline check [--clippy] | Type-check (or lint) all targets and print one line per error or warning: location, code, message, the span label and the first suggested fix. When an error comes from a zapline feature the project has not enabled, it says which (below) |
zapline test [filter] | Run the tests and print only the failures (with the start of each panic message) and a summary line. filter is a test-name filter, or a tests/<file> name |
zapline fuzz [--seed N] [--cases N] | Run tests/fuzz.rs, which fuzzes every route from its OpenAPI schemas, and print the report: each panic, 5xx, timeout, undeclared status or response that breaks its schema, with a minimal reproducing request. The report is kept for the MCP last_results tool |
zapline explain [ZL0002] | What a diagnostic code means and the code that fixes it; without a code, the list |
zapline mcp | Serve these tools to AI assistants over the Model Context Protocol (below) |
zapline routes [--json] | Print the route table |
zapline openapi [-o openapi.json] | Export the OpenAPI document |
zapline dev --record | Same, with ZAPLINE_RECORD=1: the dev console records requests for zapline snap |
zapline snap <name> [--url URL] | Save what the running app recorded as tests/snapshots/<name>.json, plus tests/snapshots.rs the first time (snapshot tests). The URL defaults to the app's ADDR/PORT, else http://127.0.0.1:8080 |
zapline client [-o FILE] | Generate a typed TypeScript (.ts) or JavaScript (.js) client for the JSON API (below). zapline dev keeps it up to date |
zapline doctor | Check rustc and cargo versions, git, docker and wrk |
zapline bench <url> [-c 256 -d 10s -t N] | Load-test with wrk |
-C <dir> runs any command as if it had been started in <dir>.
Missing features
Most of zapline is opt-in, so code copied from the guide can need a feature the project does not have yet. Plain cargo check only says could not find `ws` in `zapline`. zapline check and zapline test add the fix:
error[E0432] src/routes/chat.rs:1:14: unresolved import `zapline::ws`
could not find `ws` in `zapline`
hint: enable the `ws` feature: zapline add ws
error[E0599] src/lib.rs:12:10: no method named `views` found for struct `zapline::App` in the current scope
method not found in `zapline::App`
hint: enable the `views` feature: zapline add views
error[E0425] src/routes/posts.rs:8:20: cannot find type `Db` in this scope
not found in this scope
hint: enable the `sqlite` or `postgres` feature: zapline add sqlite
The hint covers unresolved imports and paths (zapline::ws, zapline::auth::Jwt), unknown types (WebSocketUpgrade, Views, Db, JwtConfig, Cache...) and builder methods that a feature adds (App::views, App::database, Storage::s3...). It only appears when none of the features that provide the name is on, so an error that has another cause gets no hint. The check and test MCP tools show the same hint, and add_feature applies it.
Generated layout (api template)
my-api/
├── Cargo.toml
├── src/
│ ├── main.rs # thin: builds the app and runs it
│ ├── lib.rs # pub fn app() -> App (tests use it too)
│ ├── models.rs
│ └── routes/
│ ├── mod.rs # install(app): plugins between zapline:routes markers
│ ├── health.rs
│ └── users.rs # pub fn mount(app: App) -> App
├── tests/ # in-process tests using App::test()
├── Dockerfile, .dockerignore, .env.example
└── .github/workflows/ci.yml
Each route module exposes pub fn mount(app: App) -> App, which makes it a plugin. routes/mod.rs installs the modules between // zapline:routes:start and // zapline:routes:end. zapline generate resource inserts new modules there.
The dev loop: zapline dev
zapline dev is a Vite-style loop for a Rust app. Run it in the project and open the address your app prints (http://localhost:8080 for the templates):
$ zapline dev
Watching src/, config/, migrations/, Cargo.toml, build.rs, .env
Watching templates/, static/ (browser reload, no rebuild)
Building todo
Proxy http://localhost:8080 with live reload
Running `target/debug/todo` (pid 4242) at http://localhost:8080
What each kind of change does:
| You change | What happens | Rust process |
|---|---|---|
templates/, locales/, i18n/, files in static/, public/, assets/ | Open pages reload, keeping their scroll position | Keeps running: templates and translations are read again on the next request |
a .css file in static/, public/, assets/ (including Tailwind's static/app.css) | The stylesheet is swapped in place, no page reload | Keeps running |
src/, config/, migrations/, Cargo.toml, build.rs, .env | Rebuild in the background; pages show a small "Rebuilding" badge | The old version keeps serving until the new one answers, then gets SIGTERM |
| ...and the build fails | Pages show the compiler errors (file, line, rustc's message) as an overlay | The old version keeps serving |
The swap has no downtime: while cargo compiles, every request is still answered by the previous version. The new binary starts next to it, and requests move over only once it answers HTTP; the old process then finishes its in-flight requests and exits (SIGTERM, then a kill after 5 seconds). If the new version crashes during startup, the old one keeps serving and the overlay says so.
The overlay appears in every open page and in pages you open while the build is broken. Press Esc to hide it; the next good build clears it and reloads the page. A page opened before the very first build finishes shows "Building..." and reloads by itself once the app is up.
zapline dev sets ZAPLINE_DEV=1 for the app, so 500 responses show the real error, templates and asset hashes are read again even in a --release build, and translations loaded with I18n::load are re-read when a file changes. --no-proxy turns all of this browser side off and runs the app directly: rebuild, stop the old process, start the new one.
How it works
browser ──▶ zapline dev proxy (the app's address, e.g. :8080)
│ /__zapline/events server-sent events: reload, css, problem, ...
│ /__zapline/client.js
└─▶ app on a free loopback port (127.0.0.1:41234)
zapline devstarts the app withZAPLINE_DEV_PROXY=<file>.App::runthen listens on a free loopback port instead of the address you gave it and writes both addresses to that file (zapline::dev::ENV_DEV_PROXY). Nothing else in the app changes, and withoutZAPLINE_DEV=1the variable is ignored.- The CLI listens on the app's own address and forwards every request to the app. HTML pages get one tag before
</head>:<script src="/__zapline/client.js" defer>(fragments such as htmx partials are left alone). An external script, so ascript-src 'self'policy allows it; with a nonce-based Content-Security-Policy the page's nonce is reused. Pages are fetched uncompressed so the tag can be added. A page that arrives in one go gets its new length; a page the app streams (it pauses for a moment while writing, or is over 32 MiB) is forwarded as it arrives, with the tag added on the way. Everything else (JSON, assets, streams, server-sent events, WebSocket upgrades) passes through untouched. - The client opens an
EventSourceon/__zapline/events. The CLI classifies each file change (Rust or asset), and pushesreload,css,building,problem(the parsedcargo build --message-format=jsonerrors) orclear. - Each forwarded request opens its own connection to the app, so pointing the proxy at a new version takes effect on the very next request.
- The proxy tells the app who the browser is with
X-Forwarded-For, plus a random per-run key (ZAPLINE_DEV_PROXY_KEYin the app's environment,x-zapline-dev-proxyon each request). The app's local-only pages (the dev console at/_dev, the dev inbox, dev error pages, an unguarded admin) trust thatX-Forwarded-Foronly when the key matches, and answer only when it names a single loopback address. So they work through the proxy from your machine, while a client reaching a proxy bound to0.0.0.0from elsewhere, or anything else that sends a forwarding header, still gets a 404.
The cost in production is zero: the proxy is part of the CLI, and the app side is one environment check in App::run at startup. Some limits:
- Apps served with TLS (
ServerConfig::tls), on a Unix socket, or withApp::serveon your own listener are run directly (no proxy), as with--no-proxy. An app that has not reported its address 60 seconds after starting (it does not useApp::run, or its startup is that slow) is restarted without the proxy, so it binds its own address, and the session stays direct. - The browser talks HTTP/1.1 to the proxy; the app may still serve HTTP/2 to other clients.
- Each open tab holds one event-stream connection; browsers allow about six HTTP/1.1 connections per host, so keep fewer than that many tabs of the app open.
- The proxy remembers the app's address from the last run, so the overlay also works when the first build of a session fails. On the very first run in a project it learns the address when the app first starts.
Database-backed resources
In a project with migrations/ (the fullstack template), zapline g resource writes everything a Rails or Phoenix scaffold does, as plain code you own:
zapline g resource author name
zapline g resource post title body:text published_on:date author:references
| File | Contents |
|---|---|
migrations/<ts>_create_posts.sql | The table with id, the fields, created_at and updated_at (defaulting to the current time), a foreign key and an index per reference |
src/routes/posts.rs | Post, NewPost (create/replace, JSON or form) and UpdatePost (all fields optional, for PATCH); sqlx queries; a listing() with sorting, search and filters; the JSON API under /api/posts; the pages |
templates/posts/index.html, _row.html | The list with an add form; each row has edit and delete buttons |
templates/posts/show.html, edit.html | GET /posts/{id} and GET /posts/{id}/edit. Edit swaps the row for a row of inputs (htmx), and saving sends PUT /posts/{id}, which answers with the updated row. Visited directly, both are whole pages |
tests/posts.rs | CRUD, PATCH, validation, the pages and the nested list, against an in-memory SQLite database |
| Type | Rust | SQLite | Postgres | Form input |
|---|---|---|---|---|
string (default) | String, 1 to 200 chars | TEXT | TEXT | text |
text | String | TEXT | TEXT | textarea |
int, float | i64, f64 | BIGINT, DOUBLE PRECISION | same | number |
bool | bool | BOOLEAN | BOOLEAN | checkbox |
date | chrono::NaiveDate | TEXT | DATE | type=date |
datetime | chrono::DateTime<Utc> | TEXT | TIMESTAMPTZ | type=datetime-local (read as UTC) |
uuid | uuid::Uuid | BLOB (how sqlx stores it) | UUID | text |
json | JsonValue (serde_json::Value) | TEXT | JSONB | textarea holding JSON |
author:references | author_id: i64 | BIGINT REFERENCES authors(id) ON DELETE CASCADE | same | <select> of authors |
author:ref:users | author_id: i64 | BIGINT REFERENCES users(id) ON DELETE CASCADE | same | <select> of users |
Add ? for an optional field: a nullable column and an Option<T>; an optional reference uses ON DELETE SET NULL. Dates, times and UUIDs come from zapline::chrono and zapline::uuid; the generator turns on zapline's chrono (and uuid) feature in Cargo.toml, so you don't add those crates yourself. A json field takes any JSON value in the API; in forms its textarea shows the stored value as JSON and zapline::extract::json_text parses what comes back (malformed JSON is a 422 naming the field). JSON fields are not sortable or filterable.
Resource fields
Modifiers go after the type, before or after the ?:
zapline g resource user email:string:unique name
zapline g resource review body:text product:references rating:int:index
| Modifier | Migration | API |
|---|---|---|
:unique (email:string:unique) | CREATE UNIQUE INDEX users_email_key ON users (email) | a duplicate is 409 with code email_taken |
:index (rating:int:index) | CREATE INDEX reviews_rating_idx ON reviews (rating) |
Unique columns get a unique index rather than an inline UNIQUE, so a later zapline g migration remove_email_from_users can drop the column on SQLite too. A bool can't be unique. References are always indexed.
A reference gets a ?author_id= filter on the list, GET /api/authors/{id}/posts (the author's posts, paged like the main list; 404 for an unknown author), and an author <select> in the forms that shows each author's name (or title, label, email, username, the first required text column, or the id: read from the authors migration). Rows link to the author's page when it has one. Creating or updating a post for an author that doesn't exist answers 422 with code author_not_found, before the generic 409 foreign_key_violation would.
- Another table name:
author:ref:usersnames the column and the error code afterauthor(author_id,author_not_found) and points the foreign key atusers. - Generate the parent first: when no migration creates the parent's table, the generator stops with
run zapline g resource author firstand writes nothing (Postgres won't create a foreign key to a missing table, and the migration must run after the parent's). - The same table: an optional reference to the resource's own table works,
zapline g resource category name parent:ref:categories?(orcategory:references?), withGET /api/categories/{id}/categorieslisting the children. A required one is refused: the first row would have nothing to point at. - Two references to one table (
author:ref:users editor:ref:users?) get no nested route, since/api/users/{id}/postswould be ambiguous; the generator says so, and?author_id=filters the list instead.
PUT replaces every field; PATCH takes any subset ({"title": "New"}) and leaves the rest. Both set updated_at.
Migrations from the command line
zapline g migration writes the SQL for the project's dialect from the migration's name, with the same field syntax. Each migration is a reversible pair: the .up.sql file and a .down.sql file that undoes it (DROP COLUMN, ADD COLUMN, DROP TABLE), which zapline db rollback runs:
zapline g migration add_published_at_to_posts published_at:datetime?
zapline g migration add_views_to_posts views:int=0 # the default existing rows get
zapline g migration add_email_to_users # the column from the name: email TEXT
zapline g migration add_category_to_posts category:references?
zapline g migration remove_legacy_flag_from_posts
zapline g migration create_tags name slug # id, fields, created_at, updated_at
-- migrations/20261023101500_add_published_at_to_posts.up.sql
ALTER TABLE posts ADD COLUMN published_at TIMESTAMPTZ;
-- migrations/20261023101500_add_published_at_to_posts.down.sql
ALTER TABLE posts DROP COLUMN published_at;
A required column added to a table that has rows needs a value for them: the one after = (views:int=0, status=draft, due:date=2026-01-01, pinned:bool=true), otherwise text gets DEFAULT '', numbers DEFAULT 0 (with a note naming the file, in case that is not what you want) and booleans FALSE. Dates, UUIDs and references have no zero value, so give one with = or add them as optional (due:date?), fill them in, then tighten the column in a later migration. On SQLite, DROP COLUMN needs 3.35 or newer (sqlx bundles one). Any other name with fields creates that table; without fields you get an empty pair (like zapline db new). Rolling back a remove_ migration brings the columns back empty: optional, or with their defaults.
The resource follows the table
When zapline g resource made the table, the module records what it was generated from on a // zapline:resource post title:string ... line near its top. add_<cols>_to_<table> and remove_<cols>_from_<table> then update src/routes/<table>.rs, its pages (templates/<table>/*.html) and its test (tests/<table>.rs): the structs, every query and its binds, listing()'s sort, search and filters, the forms, the table and the show page, the test bodies. The app keeps compiling, and crud_round_trip checks the new columns through POST, GET, the list, PUT and PATCH.
The update is a three-way merge, like git merge-file: the resource is generated with the old fields and with the new ones, and the difference is applied to your files, so your own edits stay. The result is what g resource would write today with the new fields, plus your changes. A file you edited right where the new code goes is left untouched, and the lines to add or remove are printed; if that file is the module, no file is changed, so the module, pages and test never disagree. A hand-written module without the // zapline:resource line (such as the fullstack template's todos, or one generated by an older zapline) gets the fields to add instead. Removing a column of a generated resource also gives the down file the column's real type.
How routes and openapi work
Both commands run your app with an environment variable. The app prints or writes what was asked for and exits before binding a port or running startup hooks:
ZAPLINE_PRINT_ROUTES=1:App::runprints the route table as JSON and exits.ZAPLINE_EXPORT_OPENAPI=<path>: the OpenAPI plugin writes the spec and exits.
So the commands are safe to run without databases or other services, for example in CI to check a committed openapi.json.
Typed clients: zapline client
zapline client -o web/src/api.ts turns the app's OpenAPI document into one TypeScript file with no dependencies (it uses fetch, so it runs in browsers, Node 18+, Deno and Bun):
- an
interfaceper schema, with the doc comments of your Rust types; createClient({ baseUrl?, headers?, fetch?, credentials?, csrf? })and a ready same-originapi, with one method per operation, named after itsoperationId(the handler name, prefixed with its module when two modules share a name:usersList,postsList). Path parameters come first, then the body, then aqueryobject, then per-call options ({ signal, headers });ApiErrorfor non-2xx responses, withstatus, and zapline'scodeanddetails; aValidationErrorfor 422s, whosefieldsare typed by the request body.
import { api, ApiError, isValidationError } from "./api";
try {
await api.createUser({ name: "", email: "ada@example.com" });
} catch (e) {
if (isValidationError(e, "createUser")) e.fields.name; // FieldError[] | undefined
else if (e instanceof ApiError && e.status === 409) { /* ... */ }
}
A .js or .mjs output (zapline client -o static/api.js) gets the same client as an ES module with JSDoc types. Routes that answer with HTML (htmx pages and fragments) are left out. Files pass tsc --strict --exactOptionalPropertyTypes --noUncheckedIndexedAccess and are only rewritten when they change.
zapline dev regenerates clients after every successful build, before the app restarts: frontend/src/api.ts when the project has a frontend/ directory, the ts/js files of a [client] table in zapline.toml, or --client <file>. Change a Rust struct, save, and your frontend's type checker sees the change: end-to-end types without a shared schema language. See Typed frontends for the whole workflow, including /_zapline/api.js for pages without a build step.
AI assistants: zapline mcp
zapline mcp is a Model Context Protocol server on stdin/stdout. It lets Claude Code, Cursor, Zed, VS Code and other MCP clients call zapline directly, and every tool answers in as few tokens as it can:
| Tool | Returns |
|---|---|
docs | No topic: the cheatsheet and the list of guide pages. With a topic ("database migrations", "htmx", "errors"): only the best-matching guide sections, from a copy of the guide built into the binary (no network) |
new_project | Creates a project from a template |
add_feature | Turns on zapline features in Cargo.toml (like zapline add); with no features, the list of features and what each one unlocks |
generate_resource, generate_migration, generate_handler, generate_middleware, generate_auth, generate_job, generate_mailer | The files created and edited |
db_new_migration | Creates a migration, with its up (sql) and down (down_sql) SQL if given |
db_migrate | Applies pending migrations and names them |
db_status | The zapline db status table, and the file to restore when an applied migration was edited or deleted |
db_rollback, db_redo, db_reset | Revert migrations. They refuse to run without confirm=true, so the assistant asks you first |
check, test | The condensed output of zapline check / zapline test |
routes | One METHOD path line per route |
openapi | The OpenAPI document, compact |
typescript_client | Writes the TypeScript client |
explain_error | A diagnostic code's cause and fix |
last_results | The latest test, check and zapline fuzz results with their age, without running anything again |
When the app does not compile, routes, openapi and the db_* tools answer with the compile errors (as check would) instead of "build failed", and when the app exits with an error, its last stderr lines are included. Any answer that mentions a diagnostic code (ZL0004 in a startup error, a panic in a test) ends with that code's fix.
Live tools: debugging the running app
While the app runs under zapline dev with the dev console, the assistant can look at it directly instead of asking you to copy logs:
| Tool | Returns |
|---|---|
recent_errors | The latest errors, newest first: code, status, request, the real message, the panic location and the fix. ZL0002 500 GET /orders: no provider registered for app::Repo`` then fix: .provide(value) // value: app::Repo; ... |
recent_requests | One 500 GET /orders 0.8ms ZL0002 line per request |
call_route | Sends a request (method, path, body, headers) and returns the status line, the headers worth reading (content type, location, cookies without their values, x-zapline-diagnostic) and the body, JSON compacted and cut at 2,000 bytes. A 500 comes back as problem+json with its code and fix |
They find the app through target/zapline-dev.addr, which zapline dev makes the app write (or $ZAPLINE_URL, $ADDR/$PORT, then http://127.0.0.1:8080; each tool also takes a url). The console only answers requests from this machine, and nothing here exists in a release build. A typical loop: the assistant edits a handler, zapline dev restarts the app, call_route shows a 500 with ZL0044, the fix says .database(Database::from_env()), the assistant adds it, and call_route returns 200.
Register it from the project directory:
claude mcp add zapline -- zapline mcp # Claude Code
{ "mcpServers": { "zapline": { "command": "zapline", "args": ["mcp"] } } }
The second form is the mcp.json / settings entry most other clients use.
Coming from other frameworks
FastAPI
| FastAPI | zapline |
|---|---|
@app.get("/items/{id}") | #[get("/items/{id}")] |
def read(id: int, q: str | None = None) | async fn read(Path(id): Path<i64>, Query(q): Query<Params>) |
| Pydantic model | #[model] struct (derives serde, JsonSchema and Validate) |
| Automatic 422 validation errors | Valid<Json<T>> → 422 with per-field details |
/docs, /openapi.json | App::openapi(OpenApi::new("API", "1.0")) |
Depends(get_db) | Dep<T>, State<T>, custom extractors, Cached<T> |
BackgroundTasks | BackgroundTasks |
HTTPException(404) | Err(Error::not_found()) |
TestClient(app) | app.test() |
uvicorn --reload | zapline dev |
| Lifespan events | on_startup / on_shutdown |
Spring Boot / ASP.NET Core / NestJS
| Concept | zapline |
|---|---|
| Controllers | Modules of #[get]/#[post] handlers mounted with .mount((...)), or Routes groups |
| Beans and services, constructor injection | App::provide, App::provide_dyn::<dyn Trait>, async provide_with factories, the Dep<T> extractor |
application.yml profiles | Config::load(): config/default.toml + config/{profile}.toml + APP__* env vars + .env |
| Filters, interceptors | Middleware: App::wrap (pre-routing), App::wrap_route / Routes::wrap (post-routing) |
Guards, @PreAuthorize | .require_roles([...]), .require_scopes([...]), Guard |
| Actuator (health, metrics) | Health and Metrics plugins |
@Scheduled | App::schedule(Schedule::every(..), job) |
| Exception handlers | App::error_handler |
| Modules / starters | Plugins |
axum
zapline will feel familiar. Extractors, IntoResponse and State work the same way. The differences:
App::run(addr)gives you a tuned thread-per-core server, with no#[tokio::main]and noserveglue.- There's no tower
Service/Layerstack. Middleware isasync fn(Request, Next) -> Response. - State is keyed by type, so you can register many types and the router has no
Sgeneric parameter. - OpenAPI, validation, DI, configuration, auth, sessions, metrics and health checks are built in.
actix-web
- Route macros look the same (
#[get("/")]), and they also document the route in OpenAPI. - Handlers and state are
Send, so any Tokio-ecosystem library works. - Both use thread-per-core runtimes. zapline uses
SO_REUSEPORTlisteners and hyper's HTTP stack.
Routing
Registering routes
There are three equivalent styles. Use whichever reads best.
#![allow(unused)] fn main() { use zapline::prelude::*; // 1. Attribute macros (also generate OpenAPI docs from the signature) #[get("/users/{id}")] async fn get_user(Path(id): Path<u64>) -> String { format!("user {id}") } // 2. Builder methods with named functions async fn list_users() -> &'static str { "[]" } fn app() -> App { App::new() .mount(get_user) .get("/users", list_users) // 3. Closures .get("/ping", || async { "pong" }) } }
#[route("/items", method = "GET", method = "POST")] registers one handler for several methods. Mount several macro routes at once with a tuple: .mount((get_user, create_user, delete_user)).
Path patterns
| Pattern | Matches | Extract with |
|---|---|---|
/users | exactly /users | — |
/users/{id} | /users/42 | Path<u64> |
/orgs/{org}/repos/{repo} | /orgs/acme/repos/web | Path<(String, String)> or a struct |
/files/{*path} | /files/a/b/c.txt | Path<String> → a/b/c.txt |
Static segments take priority over parameters, so /users/me and /users/{id} can coexist. Parameters are percent-decoded. Trailing slashes are significant. To make /users/ match /users, use NormalizePath.
Groups and nesting
Routes is a group of routes. It can carry its own middleware and metadata, and you can nest it under a prefix:
#![allow(unused)] fn main() { let admin = Routes::new() .get("/stats", stats) .delete("/users/{id}", ban_user) .wrap(require_admin); // only applies to this group let app = App::new() .nest("/admin", admin) // -> /admin/stats, /admin/users/{id} .nest("/api/v1", api_v1()); // versioning is just nesting }
Groups can be built in other modules or crates and merged with .merge(routes) or .mount(routes).
Methods, 404 and 405
- A path registered for some methods returns 405 Method Not Allowed with an
Allowheader for any other method. HEADis served automatically by theGEThandler (with no body).- Unknown paths return a JSON 404. Use
.fallback(handler)to customize it, for example to serve a SPA. .any(path, handler)matches every method that isn't otherwise registered on that path.
Route metadata
Every route carries a typed metadata map. Plugins use it: OpenAPI stores summaries and tags there, auth stores required roles, and rate limiting stores per-route policies. You can attach your own:
#![allow(unused)] fn main() { #[derive(Clone)] struct Audit(&'static str); let app = App::new() .post("/payments", pay).meta(Audit("payments")) .wrap_route(|req: Request, next: Next| async move { if let Some(Audit(kind)) = req.route().and_then(|r| r.meta::<Audit>()) { println!("audit: {kind}"); } next.run(req).await }); }
Routes::meta_all(value) applies metadata to every route in a group.
Performance notes
Routing is a radix-tree lookup (matchit) followed by a fixed-slot method match. Route parameters are stored as byte ranges into the request path, inline for up to 4 parameters, so routing never allocates.
Extractors
Handler arguments are extractors: types that know how to build themselves from the request. A handler can take up to 12 arguments. Every argument except the last must be a parts extractor, meaning it doesn't read the body. The last argument may consume the body.
#![allow(unused)] fn main() { #[post("/orgs/{org}/members")] async fn add_member( State(db): State<Db>, // app state Path(org): Path<String>, // route parameter Query(opts): Query<AddOptions>, // query string user: CurrentUser<Claims>, // auth (see the Security chapter) Json(body): Json<NewMember>, // body: must be last ) -> Result<StatusCode> { /* ... */ } }
If an extractor fails, its error response is returned and the handler isn't called. For example, a bad path parameter gives a 400 and a wrong content type gives a 415. Rejections carry a stable code, and details that locate the problem:
| Rejection | Status | code | details |
|---|---|---|---|
| Bad route parameter | 400 | invalid_path | {"param": "id"} |
| Bad query string | 400 | invalid_query | |
| Bad form body | 422 | invalid_form | |
| Malformed JSON | 422 | invalid_json | {"line": 1, "column": 9} |
| JSON of the wrong shape (missing field, wrong type...) | 422 | invalid_body | {"line": 1, "column": 22, "path": "items[0].qty"} |
Wrong Content-Type for Json / Form | 415 | unsupported_media_type |
POST /orders {"items": [{"qty": "x"}]}
HTTP/1.1 422 Unprocessable Entity
{"error":"invalid request body: invalid type: string \"x\", expected u32",
"code":"invalid_body","details":{"column":22,"line":1,"path":"items[0].qty"}}
Built-in extractors
| Extractor | Source | Failure |
|---|---|---|
Path<T> | Route parameters: a scalar, a tuple, or a struct with field names matching the parameters | 400 |
Query<T> | Query string (serde_urlencoded) | 400 |
Json<T> | JSON body. Requires Content-Type: application/json and enforces the body limit. | 415 / 413 / 422 |
Form<T> | application/x-www-form-urlencoded body | 415 / 413 / 422 |
Bytes, String | Raw body | 413 / 400 |
State<T> | App state registered with .state(v) or a startup hook | 500 if not registered |
Extension<T> | A value a middleware inserted with req.extensions_mut().insert(v) | 500 |
HeaderMap, Method, Uri | Request metadata | — |
ConnInfo | Peer address, local address, TLS flag | — |
MatchedPath | Route pattern, e.g. /users/{id} | 500 outside a route |
Request | The whole request (last argument) | — |
Option<T> | Any parts extractor, made optional | never fails |
Result<T, Error> | Any parts extractor, with its error handled by you | never fails |
Feature modules add more: Valid<T>, Dep<T>, BackgroundTasks, BearerToken, Jwt<C>, CurrentUser<U>, CookieJar, Session, Multipart, BodyStream, WebSocketUpgrade, RequestId. Each is covered in its own chapter.
Path parameters in detail
#![allow(unused)] fn main() { #[get("/users/{id}")] async fn a(Path(id): Path<u64>) {} #[get("/repos/{owner}/{name}")] async fn b(Path((owner, name)): Path<(String, String)>) {} #[derive(serde::Deserialize)] struct RepoPath { owner: String, name: String } #[get("/repos/{owner}/{name}/issues")] async fn c(Path(p): Path<RepoPath>) {} }
Numbers, booleans, chars, enums (unit variants) and Option<T> are parsed from the textual value. A value that doesn't parse returns 400 with a message that names the parameter.
Body limits
Every buffering extractor enforces the app's body limit (default 2 MiB, configurable with App::body_limit(bytes)). Oversized bodies are rejected with 413 before they're read in full when Content-Length is known, and as soon as the limit is crossed otherwise.
Writing your own extractor
Implement FromRequestParts (or FromRequest to consume the body). Both use async fn:
#![allow(unused)] fn main() { use zapline::extract::FromRequestParts; use zapline::prelude::*; pub struct Tenant(pub String); impl FromRequestParts for Tenant { async fn from_request_parts(req: &mut Request) -> Result<Self> { req.headers() .get("x-tenant") .and_then(|v| v.to_str().ok()) .map(|t| Tenant(t.to_owned())) .ok_or_else(|| Error::bad_request("missing x-tenant header").with_code("missing_tenant")) } } async fn handler(Tenant(t): Tenant) -> String { t } }
An extractor can do async work too, such as looking up a session or calling a service. To compute a value only once per request when several extractors need it, wrap it in Cached<T> (see Dependency injection).
Responses
A handler can return anything that implements IntoResponse:
| Return type | Response |
|---|---|
&'static str, String | 200 text/plain; charset=utf-8 |
Bytes, Vec<u8> | 200 application/octet-stream |
Json<T> | 200 application/json |
Html<T> | 200 text/html; charset=utf-8 |
StatusCode, () | Empty body with that status (() → 200) |
Redirect::to(uri) / temporary / permanent | 303 / 307 / 308 with Location |
Option<T> | T, or a JSON 404 for None |
Result<T, E> | T or E (any IntoResponse, usually zapline::Error) |
(StatusCode, T) | T with that status |
([(HeaderName, HeaderValue); N], T) | T with extra headers |
(StatusCode, [(HeaderName, HeaderValue); N], T) | Both |
Response | Full control |
#![allow(unused)] fn main() { use zapline::prelude::*; use zapline::header; // `status = 201` documents the status the handler picks at run time. #[post("/users", status = 201)] async fn create(Json(u): Json<NewUser>) -> Result<(StatusCode, Json<User>)> { let user = save(u).await?; Ok((StatusCode::CREATED, Json(user))) } async fn download() -> impl IntoResponse { ( [(header::CONTENT_DISPOSITION, HeaderValue::from_static("attachment; filename=\"report.csv\""))], "id,name\n1,ada\n", ) } }
Streaming responses such as Server-Sent Events, file downloads and NDJSON are covered in Server-Sent Events & streaming.
Custom response types
#![allow(unused)] fn main() { struct Csv(String); impl IntoResponse for Csv { fn into_response(self) -> Response { ([(zapline::header::CONTENT_TYPE, HeaderValue::from_static("text/csv"))], self.0).into_response() } } }
Errors
zapline has one error type, zapline::Error, and one result alias, zapline::Result<T>.
#![allow(unused)] fn main() { #[get("/users/{id}")] async fn get_user(State(db): State<Db>, Path(id): Path<u64>) -> Result<Json<User>> { let user = db.find(id).await? // any std::error::Error -> 500 .ok_or_else(Error::not_found)?; // explicit client error -> 404 Ok(Json(user)) } }
Rules
-
Any
std::error::Errorconverts into a 500 with?. The underlying error is logged (throughtracingwhen enabled) and never sent to the client. The client sees{"error":"internal server error"}. -
Client errors are explicit. Use the constructors:
bad_request(msg),unauthorized(),forbidden(),not_found(),conflict(msg),unprocessable(msg),too_many_requests(),service_unavailable(msg),payload_too_large(),unsupported_media_type(msg), orError::new(status, msg). -
Errors are structured. Each one renders as JSON:
{ "error": "validation failed", "code": "validation_error", "details": [ ... ] }codeanddetailsare omitted when unset.
#![allow(unused)] fn main() { Err(Error::conflict("email already registered") .with_code("email_taken") // stable, machine-readable .with_details(json!({ "field": "email" })) // structured context .with_header(header::RETRY_AFTER, HeaderValue::from_static("30"))) }
Customizing the error format
Every error response includes an ErrorReport (status, message, code, details) in its extensions. That covers extractor rejections, handler errors, 404s and 405s. App::error_handler re-renders all of them in one place. For example, to emit RFC 7807 application/problem+json:
#![allow(unused)] fn main() { App::new().error_handler(|e: &ErrorReport| { let body = serde_json::json!({ "type": "about:blank", "title": e.status.canonical_reason(), "status": e.status.as_u16(), "detail": e.message, "code": e.code, }); (e.status, [(header::CONTENT_TYPE, HeaderValue::from_static("application/problem+json"))], body.to_string()) .into_response() }) }
Domain errors
Map your own error enums into zapline::Error once, and ? will work everywhere:
#![allow(unused)] fn main() { enum AppError { NotFound, Db(sqlx::Error) } impl From<AppError> for zapline::Error { fn from(e: AppError) -> Self { match e { AppError::NotFound => Error::not_found().with_code("user_not_found"), AppError::Db(e) => Error::internal("database error").with_source(e), } } } }
(Implement From for your error type instead of std::error::Error. Otherwise, the blanket conversion turns it into a plain 500.)
Diagnostic codes
The mistakes you make while building an app (a dependency nobody provides, a duplicate route, a config key nobody set, a template that does not parse, a request body that does not match its type) have stable codes, ZL0001 to ZL0091. Each has a title, the cause, and the fix written as the code to add. The code appears wherever the mistake surfaces:
$ cargo run
startup failed: 500 Internal Server Error: resource `my_app::Settings` is not registered [ZL0004]
fix: Register it first: .state(value) // value: my_app::Settings; or res.insert(value) in an earlier on_startup hook
see: zapline explain ZL0004
| Where | What you get |
|---|---|
| Startup errors and panics on invalid setup (duplicate routes, conflicting paths, missing Cargo features) | [ZL0010] after the message, then the fix and zapline explain ZL0010 |
Error::diagnostic_code() | Some("ZL0002") for an error the framework recognized |
| The dev error page | The code, the cause, the fix and a prompt for your AI assistant |
The dev console and GET /_dev/api | Recent errors with their codes and fixes |
zapline explain ZL0002 | The full entry; zapline explain lists them all |
zapline mcp | explain_error, and every tool answer that mentions a code ends with its fix |
Codes are never renumbered or reused, so they are safe to search for, to match on in tests and to put in a bug report. They are diagnostics for developers: the JSON your clients see keeps its own code (missing_dependency, invalid_json), and the ZL code is never added to it.
#![allow(unused)] fn main() { let err = Resources::default().dep::<Repo>().unwrap_err(); assert_eq!(err.code(), Some("missing_dependency")); // sent to clients assert_eq!(err.diagnostic_code(), Some("ZL0002")); // for you }
The registry lives in one file, crates/zapline/src/codes.in, also available as zapline::codes::ALL and zapline::codes::lookup("ZL0002"). The index below is generated from it.
The dev error page
In a debug build, App::dev_errors() turns every 500 and every panic into a page that says what broke, for requests from your own machine:
#![allow(unused)] fn main() { App::new() .dev_errors() // also on with .dev_console(), and whenever ZAPLINE_DEV=1 (zapline dev sets it) .mount(routes) .run("127.0.0.1:8080") }
-
Browsers (
Accept: text/html) get an HTML page with the real error and its causes, the code and its fix, the file and line of a panic, the request with its headers (credentials hidden), and a Copy fix prompt block: one click copies a ready-made prompt for your AI assistant with the request, the error, the cause and the suggested fix. -
JSON clients get RFC 9457
application/problem+json:{"type":"https://samsamurai301.github.io/zapline/errors.html#zl0002","title":"No provider for a dependency", "status":500,"detail":"no provider registered for `my_app::Repo`","error":"no provider registered for `my_app::Repo`", "code":"ZL0002","error_code":"missing_dependency","fix":".provide(value) // value: my_app::Repo; ...", "request":"GET /orders"}(
errorrepeatsdetailso clients that read the usual{"error": ..}keep working;error_codeis the client-facing code.) -
Rejected requests (a 4xx the framework recognized, such as
invalid_json) keep their normal body and gain anx-zapline-diagnostic: ZL0062header.
It is safe to leave in. A release build compiles it out (.dev_errors() does nothing), requests from other machines or through a proxy get the usual generic error, and when it is off nothing is added to the request path: the middleware is installed when the app is built, only if dev errors are on.
Code index
| Code | Title |
|---|---|
| ZL0001 | State not registered |
| ZL0002 | No provider for a dependency |
| ZL0003 | A dependency failed to initialize |
| ZL0004 | Resource not registered |
| ZL0005 | Request extension missing |
| ZL0010 | Duplicate route |
| ZL0011 | Invalid or conflicting route path |
| ZL0012 | Route metadata without a route |
| ZL0013 | Route information used before routing |
| ZL0020 | Cargo feature not enabled |
| ZL0021 | Database URL does not match the backend |
| ZL0030 | Config file unreadable |
| ZL0031 | Config file malformed |
| ZL0032 | Config key missing |
| ZL0033 | Config value has the wrong type |
| ZL0034 | Invalid config profile name |
| ZL0040 | Database URL not set |
| ZL0041 | Cannot connect to the database |
| ZL0042 | Migrations unreadable |
| ZL0043 | Migration failed |
| ZL0044 | No database registered |
| ZL0045 | Database registered too late |
| ZL0046 | No seed data |
| ZL0050 | No templates registered |
| ZL0051 | Template error |
| ZL0060 | Path parameter rejected |
| ZL0061 | Query string rejected |
| ZL0062 | Malformed JSON body |
| ZL0063 | JSON body has the wrong shape |
| ZL0064 | Wrong Content-Type |
| ZL0065 | Form body rejected |
| ZL0066 | Validation failed |
| ZL0067 | Body too large |
| ZL0068 | Body too slow |
| ZL0070 | No mailer registered |
| ZL0071 | No cache registered |
| ZL0072 | No Redis registered |
| ZL0073 | No cookie key |
| ZL0074 | No CSRF middleware |
| ZL0075 | JWT not configured |
| ZL0076 | No translations registered |
| ZL0077 | No file storage registered |
| ZL0078 | Authorization misconfigured |
| ZL0080 | Handler panicked |
| ZL0081 | Unhandled error |
| ZL0090 | Dev console not mounted |
| ZL0091 | Dev app not running |
ZL0001
State not registered. A handler takes State<T>, but no value of that exact type was registered. State is looked up by type, so State<Arc<Db>> and State<Db> are different keys.
App::new().state(value) // value: T; or in a startup hook: res.insert(value)
ZL0002
No provider for a dependency. A handler takes Dep<T> (or code calls res.dep::<T>()), but nothing provides T. Providers are registered on the app before it starts.
.provide(value) // value: T; to build it at startup: .provide_with(|res: Resources| async move { Ok(value) }); for a trait object: .provide_dyn::<dyn Trait>(Arc::new(imp))
ZL0003
A dependency failed to initialize. The factory given to provide_with / provide_injected returned an error, so the app did not start. The message ends with the factory's own error.
Fix the cause named in the message; inside the factory, resolve what it needs with res.dep::<Other>()? and register Other before it
ZL0004
Resource not registered. Resources::require::<T>() found no value of type T. Startup hooks run in the order they were registered, so a hook can only require what an earlier hook or .state(..) inserted.
Register it first: .state(value) // value: T; or res.insert(value) in an earlier on_startup hook
ZL0005
Request extension missing. A handler takes Extension<T>, but no middleware inserted a T into this request's extensions, or the middleware runs after the handler is reached.
In a middleware that runs before the handler: req.extensions_mut().insert(value); // value: T, installed with .wrap(..) or .wrap_route(..)
ZL0010
Duplicate route. Two handlers were registered for the same method and path. zapline refuses to pick one silently; zapline routes prints the whole table.
Remove one of the two registrations of T, or change its method or path
ZL0011
Invalid or conflicting route path. The router cannot add this path: two routes give the same segment different parameter names (/users/{id} and /users/{name}), a catch-all is not the last segment, or a brace is unbalanced.
Use one parameter name per position (/users/{id} everywhere) and keep catch-alls last: /files/{*path}
ZL0012
Route metadata without a route. Routes::meta(..) (or App::meta) attaches metadata to the route registered just before it, and none was registered yet.
Call it right after the route: .get("/x", handler).meta(value)
ZL0013
Route information used before routing. MatchedPath (or req.route()) was used in global middleware, which runs before the router has matched the request.
Install that middleware after routing: .wrap_route(mw) instead of .wrap(mw)
ZL0020
Cargo feature not enabled. The app asks for something that is compiled only with a zapline Cargo feature that is off.
In Cargo.toml: zapline = { version = "0.1", features = ["T"] }
ZL0021
Database URL does not match the backend. The database URL names one backend (postgres://, sqlite://) while the app's Database uses the other, usually because only one of the postgres and sqlite features is enabled.
Use a URL for the compiled backend (T), or enable the other feature: zapline = { version = "0.1", features = ["postgres"] } and Database::postgres(url)
ZL0030
Config file unreadable. A configuration file exists but cannot be read (permissions, a directory in its place, invalid UTF-8).
Fix the file's permissions or contents: T
ZL0031
Config file malformed. A TOML layer (config/default.toml, config/<profile>.toml) or .env does not parse. The message gives the file and the line.
Fix the syntax at the reported line; TOML strings need quotes: name = "value"
ZL0032
Config key missing. The settings struct has a required field that no layer sets.
Set T in config/default.toml, or as an environment variable (APP__ prefix, __ between levels, e.g. APP__SERVER__PORT=8080), or make the field Option<_> / #[serde(default)]
ZL0033
Config value has the wrong type. A config value does not deserialize into the field's type. The message says which layer set it.
Give T a value of the field's type in the layer named in the message (numbers unquoted: port = 8080)
ZL0034
Invalid config profile name. The profile (APP_PROFILE or Config::builder().profile(..)) contains characters other than ASCII letters, digits, - and _.
Use a plain name: APP_PROFILE=production
ZL0040
Database URL not set. Database::from_env() reads DATABASE_URL (or the variable you named), and it is not set.
Add to .env: DATABASE_URL=sqlite://app.db?mode=rwc (or postgres://user:pass@localhost/app)
ZL0041
Cannot connect to the database. The pool could not open a connection at startup: the server is down, the credentials are wrong, or a SQLite file cannot be created.
Check DATABASE_URL; for SQLite add ?mode=rwc so the file is created; for Postgres start it, e.g. docker run -p 5432:5432 -e POSTGRES_PASSWORD=pass postgres
ZL0042
Migrations unreadable. The migrations directory exists but its files cannot be read or are not named <version>_<name>.sql.
Create migrations with: zapline db new <name> (names them <timestamp>_<name>.sql)
ZL0043
Migration failed. A migration's SQL failed, or an applied migration was edited (its checksum changed). Applied migrations are immutable.
Fix the SQL of the migration named in the message; to change an applied one, add a new migration: zapline db new fix_<name> (in development you can also delete the SQLite file)
ZL0044
No database registered. A handler takes Db, but the app never called .database(..). With both the postgres and sqlite features on, Db means Postgres; use SqliteDb for SQLite.
App::new().database(Database::from_env())
ZL0045
Database registered too late. Jobs and seed data use the pool that .database(..) creates at startup, so it has to be registered before them.
App::new().database(Database::from_env()).jobs(Jobs::new().register::<MyJob>()).seed(|db| async move { Ok(()) })
ZL0046
No seed data. zapline db seed ran, but the app has no seed function.
.seed(|db| async move { Factory::new("todos").create(&db).await?; Ok(()) }) // after .database(..)
ZL0050
No templates registered. A handler takes Views, but the app has no template directory.
App::new().views("templates")
ZL0051
Template error. A template does not exist, does not parse, or failed while rendering (an undefined filter, a wrong type). The message names the template and line. Templates are found relative to the working directory, i.e. the project root under cargo run.
Fix the template at the reported line; extend layouts with {% extends "base.html" %} and pass every variable the template uses: views.render("page.html", context! { title })
ZL0060
Path parameter rejected. A Path<T> parameter did not parse into its type, e.g. /users/abc for Path<u64>. The client gets 400 invalid_path.
Send a value of the parameter's type, or take it as text: Path(id): Path<String>
ZL0061
Query string rejected. Query<T> could not deserialize the query string: a missing required field or a value of the wrong type. The client gets 400 invalid_query.
Make optional fields Option<_> or #[serde(default)] in the query struct
ZL0062
Malformed JSON body. The body of a Json<T> request is not valid JSON. details has the line and column. The client gets 422 invalid_json.
Send valid JSON with Content-Type: application/json, e.g. curl -H 'content-type: application/json' -d '{"name":"x"}'
ZL0063
JSON body has the wrong shape. The body is valid JSON but does not match T: a missing field, a wrong type, an unknown variant. details.path points at the value. The client gets 422 invalid_body.
Match the request type, or relax it: Option<_> / #[serde(default)] for fields clients may leave out
ZL0064
Wrong Content-Type. Json<T> needs application/json and Form<T> needs application/x-www-form-urlencoded. The client gets 415 unsupported_media_type.
Send the header: Content-Type: application/json (fetch: headers: { 'content-type': 'application/json' })
ZL0065
Form body rejected. Form<T> could not deserialize the form. The client gets 422 invalid_form.
Name the inputs like the struct's fields; checkboxes are absent when unchecked, so use #[serde(default)] bool
ZL0066
Validation failed. Valid<T> ran the struct's #[validate(..)] rules and some failed. details lists them per field. The client gets 422 validation_error.
Send values that pass the rules shown in details, or change the rules: #[validate(length(min = 1))]
ZL0067
Body too large. The request body is larger than the app's limit (2 MiB by default). The client gets 413.
Raise the limit: App::new().body_limit(16 * 1024 * 1024); or stream the body with BodyStream
ZL0068
Body too slow. The client took longer than ServerConfig::body_read_timeout (30s by default) to send a buffered request body. The client gets 408 and the connection is closed, so slow senders cannot hold connections open.
Raise the timeout: app.server_config_mut().body_read_timeout = Some(Duration::from_secs(120)); or stream large uploads with BodyStream or Multipart
ZL0070
No mailer registered. A handler takes Mailer, but the app has none.
App::new().mailer(Mailer::from_env()?) // SMTP_URL; without it, mail goes to the dev inbox at /_dev/mail
ZL0071
No cache registered. A handler takes Cache, but the app has none.
App::new().cache(Cache::memory())
ZL0072
No Redis registered. A handler takes Redis, but the app has none.
App::new().redis(Redis::from_env()?) // REDIS_URL=redis://localhost:6379
ZL0073
No cookie key. Signed or private cookies (and sessions) need a key, and none was registered.
App::new().cookie_key(zapline::cookies::key_from_env("SESSION_SECRET")?) // 32+ random bytes: openssl rand -base64 48
ZL0074
No CSRF middleware. A handler takes CsrfToken, but the Csrf middleware that issues tokens is not installed.
App::new().wrap(Csrf::new())
ZL0075
JWT not configured. JWT extraction or signing needs a JwtConfig, and none was registered.
App::new().jwt(JwtConfig::hs256(std::env::var("JWT_SECRET")?))
ZL0076
No translations registered. A handler takes Locale, but the app has no translations.
App::new().i18n(I18n::load("locales")?)
ZL0077
No file storage registered. A handler takes Storage, but the app has none.
App::new().storage(Storage::from_env()?) // a local directory, or S3 when S3_BUCKET is set
ZL0078
Authorization misconfigured. A route requires roles or scopes, but nothing checks them after routing, or a Guard was installed as global middleware. Policies fail closed: the route answers 500 instead of letting the request through.
Check roles after routing: .wrap_route(Authenticate::new(authenticator).authorize()) or .wrap_route(guard), never .wrap(guard)
ZL0080
Handler panicked. A handler or middleware panicked. The panic was caught and answered with 500. Panics usually come from unwrap(), expect() or indexing.
Return an error instead of panicking: replace .unwrap() with ? (or .ok_or_else(Error::not_found)?) in the function at the reported location
ZL0081
Unhandled error. An error reached the client as a 500 through ?: any std error converts into a logged 500 with a generic message. Client mistakes should get a 4xx instead.
Map expected failures to client errors: .ok_or_else(Error::not_found)? or .map_err(|e| Error::bad_request(e.to_string()))?; keep real failures as Error::internal("what failed").with_source(e)
ZL0090
Dev console not mounted. The live tools of zapline mcp read the running app's /_dev console, which this app does not serve. It exists only in debug builds.
App::new().dev_console() // debug builds only; does nothing in release
ZL0091
Dev app not running. Nothing answers at the app's address. The live tools need the app running, ideally under zapline dev, which records its address in target/zapline-dev.addr.
Start it: zapline dev (or pass the address: url = "http://127.0.0.1:8080")
Middleware
Middleware is an async function that receives the request plus next, the rest of the pipeline:
#![allow(unused)] fn main() { use std::time::Instant; use zapline::prelude::*; async fn timing(req: Request, next: Next) -> Response { let start = Instant::now(); let mut res = next.run(req).await; let ms = start.elapsed().as_secs_f64() * 1000.0; res.headers_mut().insert("server-timing", HeaderValue::from_str(&format!("app;dur={ms:.2}")).unwrap()); res } App::new().wrap(timing); }
Middleware can short-circuit by returning a response without calling next.run. It can also modify the request (headers, extensions, URI) before passing it on, and modify the response afterwards. For configurable middleware, implement the Middleware trait on a struct.
Where middleware runs
request ─▶ global (App::wrap) ─▶ routing ─▶ route (App::wrap_route) ─▶ group (Routes::wrap) ─▶ handler
| Kind | Registered with | Sees 404s? | req.route() known? | Typical use |
|---|---|---|---|---|
| Global | App::wrap | yes | no | CORS, request IDs, path normalization, tracing, compression, security headers |
| Route | App::wrap_route | no | yes | metrics (route labels), auth policies, per-route rate limits |
| Group | Routes::wrap | no | yes | auth for /admin/*, API-version-specific behavior |
Middleware runs in the order it was added (the first wrap is the outermost layer).
Passing data to handlers
Insert typed values into the request extensions and read them with Extension<T>:
#![allow(unused)] fn main() { #[derive(Clone)] struct Tenant(String); async fn tenant(mut req: Request, next: Next) -> Response { let t = req.headers().get("x-tenant").and_then(|v| v.to_str().ok()).unwrap_or("default").to_owned(); req.extensions_mut().insert(Tenant(t)); next.run(req).await } async fn handler(Extension(Tenant(t)): Extension<Tenant>) -> String { t } }
Built-in middleware
| Middleware | Purpose |
|---|---|
Logger | One line per request: method, path, route, status, latency |
Cors | CORS with permissive or allow-list configuration and preflight handling |
Timeout | Fail requests that exceed a duration with 503 |
DefaultHeaders | Add headers to every response |
Compression | gzip, br, zstd or deflate, negotiated with q-values (feature compression) |
RateLimit | Per-IP, per-key, global and per-route limits with standard RateLimit-* headers |
SetRequestId / RequestId | Generate and propagate x-request-id |
SecurityHeaders | Hardened default response headers (HSTS, nosniff, frame options, CSP, ...) |
CatchPanic | Turn handler panics into 500s |
ConcurrencyLimit | Cap in-flight requests, with load shedding |
NormalizePath | Trailing-slash and duplicate-slash normalization |
telemetry::Trace | tracing spans and events per request |
See Hardening middleware for configuration details.
State, lifecycle & plugins
State
Register any Clone + Send + Sync + 'static value and extract it with State<T>:
#![allow(unused)] fn main() { #[derive(Clone)] struct AppConfig { greeting: String } let app = App::new() .state(AppConfig { greeting: "hi".into() }) .get("/", |State(cfg): State<AppConfig>| async move { cfg.greeting }); }
State is keyed by type, so you can register as many types as you want. State<T> clones T for each request, so wrap large or mutable data in an Arc (connection pools are already cheap to clone). For trait objects and services, prefer Dep<T>.
Startup and shutdown hooks
Resources that need async setup, such as database pools, caches, clients or warmed data, belong in a startup hook. Hooks run in registration order before the server accepts connections. Anything a hook inserts into Resources becomes available as State<T>:
#![allow(unused)] fn main() { App::new() .on_startup(|res: Resources| async move { let url = res.require::<Settings>()?.database_url; res.insert(Db::connect(&url).await?); // -> State<Db> Ok(()) }) .on_shutdown(|res: Resources| async move { if let Some(db) = res.get::<Db>() { db.close().await; } }) }
- If a startup hook fails,
App::runreturns the error and never binds the port. - Shutdown hooks run in reverse order, after the server has drained its connections.
- Hooks run on a small long-lived system runtime. Resources created there, like pool connections and background jobs, keep being driven for the life of the server.
- In tests,
App::test()runs startup hooks lazily on the first request. Callclient.start().awaitto run them eagerly, andclient.shutdown().awaitto run the shutdown hooks.
Plugins
A plugin is a reusable bundle of routes, middleware, state and hooks. Any function FnOnce(App) -> App is a plugin:
#![allow(unused)] fn main() { pub fn admin_console(app: App) -> App { app.nest("/admin", admin_routes()) .on_startup(|res| async move { /* ... */ Ok(()) }) } App::new().plugin(admin_console); }
For configurable plugins, implement the Plugin trait on a struct. The built-in OpenApi, Health and Metrics plugins all do this.
Build hooks
App::on_build(|routes: &[&'static RouteInfo]| -> Routes) runs once the route table is final. A plugin can inspect every route and its metadata, then add more routes. This is how /openapi.json is generated once at startup instead of on each request:
#![allow(unused)] fn main() { App::new().on_build(|routes| { let listing: Vec<String> = routes.iter().map(|r| r.path().to_owned()).collect(); Routes::new().get("/_routes", move || { let body = listing.join("\n"); async move { body } }) }) }
Builder-time storage
App::extensions_mut() is a typed map that plugins can use to share configuration while the app is being assembled.
Testing
App::test() turns an app into an in-process client. It runs the full pipeline (global middleware, routing, route middleware, extractors, handlers, error rendering) without opening a socket, so tests are fast and deterministic.
#![allow(unused)] fn main() { use zapline::prelude::*; fn app() -> App { App::new().mount((create_user, get_user)) } #[tokio::test] async fn creates_and_fetches_a_user() { let client = app().test(); let res = client .post("/users") .json(&serde_json::json!({ "name": "ada", "email": "ada@example.com" })) .send() .await; assert_eq!(res.status(), StatusCode::CREATED); let created: User = res.json(); let res = client.get(&format!("/users/{}", created.id)).send().await; assert_eq!(res.status(), 200); assert_eq!(res.json::<User>().name, "ada"); } }
Request builder
| Method | Effect |
|---|---|
.header(name, value) | Add a header |
.bearer(token) | Authorization: Bearer <token> |
.query(&value) | Append a URL-encoded query string (after any query already in the URI) |
.json(&value) / .form(&value) / .body(bytes) | Request body, with the matching content type |
.multipart(form) | A multipart/form-data body built with zapline::test::MultipartForm |
.remote_addr(addr) | Pretend the request came from this peer. The default is 127.0.0.1. |
#![allow(unused)] fn main() { use zapline::test::MultipartForm; let form = MultipartForm::new() .text("title", "Holiday") .file("photo", "beach.png", "image/png", png_bytes); let res = client.post("/upload").multipart(form).send().await; }
Response helpers
status(), headers(), header(name) -> Option<&str>, header_all(name) -> Vec<&str>, text(), bytes(), json::<T>() (panics with the body text if parsing fails) and cookie(name), which returns the response's Set-Cookie for that cookie with its attributes (value(), path(), max_age(), http_only(), is_removal()...).
assert_status(..) and assert_header(name, value) return the response, so they chain, and their panic messages include the response body:
#![allow(unused)] fn main() { client.post("/users").json(&new_user).send().await .assert_status(StatusCode::CREATED) .assert_header("content-type", "application/json"); // panics with: expected status 201, got 422 Unprocessable Entity // response body: {"error":"invalid request body: missing field `email`",...} }
Cookies and sessions
app.test() is stateless: each request carries only the headers you set. .with_cookies() gives the client a cookie store, shared by its clones, that works like a browser's: it keeps every Set-Cookie it is allowed to and sends the matching cookies back in one Cookie header. That makes login flows one line per step:
#![allow(unused)] fn main() { let browser = app().test().with_cookies(); browser.post("/login").json(&credentials).send().await.assert_status(200); browser.get("/me").send().await.assert_status(200); browser.post("/logout").send().await; assert_eq!(browser.cookie("zapline.sid"), None); }
Calling .with_cookies() again starts a separate store, e.g. for a second user. The store applies the rules browsers do (RFC 6265 and 6265bis):
| Attribute | In the test store |
|---|---|
Domain | Without it, a cookie is host-only. With it, the cookie also goes to subdomains. A Domain the host doesn't belong to, or a public suffix (com, co.uk), is rejected. |
Path | Prefix match, defaulting to the request's directory. Longer paths are sent first. |
Secure | Stored and sent only on secure requests: https, or a loopback host such as the default http://localhost. A plain-http response can't overwrite a Secure cookie. |
SameSite | Applied to requests another page starts (below). Strict stays home on cross-site requests; Lax (also the default) goes only with safe top-level navigations; None always, and needs Secure. |
Max-Age, Expires | Max-Age wins. Expired cookies are deleted and never sent. |
HttpOnly | Kept, and hidden from document_cookie. |
__Secure-, __Host- | __Secure- needs Secure; __Host- also needs Path=/ and no Domain. |
Cookies you set with .header("cookie", ..) are sent first and win over stored ones with the same name.
Origins and cross-site requests
Relative URIs go to http://localhost unless you pick the site with .base_url("https://app.example.com"): requests then get a Host header, and https makes them secure (ConnInfo::secure). Absolute URIs (client.get("https://api.example.com/x")) use their own origin.
.from_origin(url) sends a request as if a page on url started it with a link or a form, and .fetch_from(url) as a fetch with credentials. They add the Origin and Sec-Fetch-Site/Sec-Fetch-Mode headers a browser would, and the store applies SameSite. Sites are compared like browsers do, by scheme and registrable domain, using a short built-in list of multi-label public suffixes (co.uk, github.io, ...) instead of the full Public Suffix List. So you can test CSRF defenses against what a browser would really send:
#![allow(unused)] fn main() { let browser = app().test().with_cookies().base_url("https://app.example.com"); browser.post("/login").json(&credentials).send().await.assert_status(200); // A form on another site: the Lax session cookie stays home, and // `SameOrigin` refuses the request. browser.post("/transfer").from_origin("https://evil.example").send().await.assert_status(403); // A sibling subdomain is the same site, and the cookie goes along. browser.post("/transfer").from_origin("https://blog.example.com").send().await; }
To look inside the store:
| Method | Returns |
|---|---|
cookie(name) | The value of a stored, unexpired cookie |
cookies() | Every stored cookie with its attributes (domain, host_only, path, secure, http_only, same_site, expires) |
document_cookie(url) | What document.cookie would return on that page: matching cookies that aren't HttpOnly |
set_cookie(url, set_cookie) | Stores a Set-Cookie as if that page sent it, with every rule applied, and returns whether it was accepted. Use it to simulate another site planting a cookie. |
clear_cookies() | Empties the store |
#![allow(unused)] fn main() { // JavaScript can read the CSRF token but never the session id. let js = browser.document_cookie("https://app.example.com/"); assert!(js.contains("__Host-zapline.csrf=") && !js.contains("zapline.sid")); // evil.example.com can plant a cookie for all of example.com, but not a __Host- one. assert!(browser.set_cookie("https://evil.example.com/", "csrf=x; Domain=example.com")); assert!(!browser.set_cookie("https://evil.example.com/", "__Host-csrf=x; Domain=example.com; Path=/; Secure")); }
Not modelled: redirects are not followed, there is no per-domain cookie limit or eviction, and the Chrome "Lax+POST" two-minute exception for cookies without a SameSite attribute is not applied.
Fuzz every route
Your OpenAPI document already says what every route accepts and returns, so zapline can write the tedious tests for you. client.fuzz() walks the document and sends each operation a few hundred requests generated from its own JSON Schemas:
#![allow(unused)] fn main() { #[tokio::test] async fn every_route_survives_fuzzing() { app().test().fuzz().await.assert_clean(); } }
For every operation it sends, in this order:
- a typical valid request, using schema examples when there are some;
- boundary values that are still valid: empty and maximum-length strings, unicode (combining marks, emoji, right-to-left text, NUL),
0,-1and the limits of each integer type (u8::MAX,i32::MIN...), tiny and huge floats, empty and long arrays, objects with only their required fields or with all of them; - invalid input: each required parameter and field missing, wrong types, numbers out of range, strings shorter than
minLengthor longer thanmaxLength, unknown enum variants and properties, malformed and deeply nested JSON, the wrong content type, a body one byte over the size limit; - random requests (a third of their values at the boundaries) until the budget of 100 requests per operation is spent.
A response is a failure when:
| Failure | Meaning |
|---|---|
panicked | The handler (or a middleware) panicked. Panics caught by CatchPanic count too. |
server error | Any 5xx. |
timed out | No response within the timeout (5 s). |
undeclared status | A status the operation does not declare: its code, its range (4XX) or, for errors, default. A 201 from a route documented as 200 is caught here; declare it with #[post("/items", status = 201)]. Invalid requests may also get the framework's rejections (400, 404, 405, 411, 413, 414, 415, 422, 431). |
response does not match its schema | A 2xx JSON body that breaks the declared response schema, with every mismatch listed by JSON pointer. |
Each failure is reported once per operation and kind, after shrinking: optional parameters and fields are dropped and strings and arrays shortened for as long as the same failure reproduces. The report gives the minimal request and a line of test code that replays it:
zapline fuzz: 6 operations, 642 requests, seed 24301: 2 failures
1) GET /ratio/{n} (ratio): panicked: attempt to divide by zero at src/routes/math.rs:12:5
case: boundary integer for path parameter `n` (hit by 8 requests)
request: GET /ratio/0
response: none
reproduce: client.request(Method::GET, "/ratio/0").send().await
2) GET /stats (stats): response does not match its schema (1 error)
- /count: expected integer, got string "many"
case: valid request (hit by 100 requests)
request: GET /stats
response: 200 OK {"count":"many"}
reproduce: client.request(Method::GET, "/stats").send().await
rerun the same requests with ZAPLINE_FUZZ_SEED=24301
Paste the reproduce: line into a regular test to keep the bug fixed. The fuzzer checks the app against the document its OpenApi plugin serves (including .modify(..) hooks), or one generated with the default settings when there is no plugin, so a failure is either a bug in the handler or a gap in the docs: both are worth fixing.
Configuration
#![allow(unused)] fn main() { use std::time::Duration; use zapline::test::FuzzConfig; let report = app() .test() .fuzz() .config( FuzzConfig::new() .seed(7) // same seed, same requests .cases(500) // requests per operation .bearer("test-token") // sent with every request .header("x-tenant", "acme") .timeout(Duration::from_secs(1)) .allow_status(409) // accepted from every operation .only("/admin") // operation id, `METHOD /path` or path prefix .skip("POST /webhooks"), ) .await; for failure in report.failures() { println!("{} -> {}", failure.request(), failure.kind()); } report.assert_clean(); }
Runs are deterministic: the requests depend only on the seed and the document, and each operation has its own stream of cases, so adding a route does not change what the others get. When the test does not set them, the ZAPLINE_FUZZ_SEED and ZAPLINE_FUZZ_CASES environment variables choose the seed and the budget, which is how zapline fuzz --seed 7 --cases 1000 explores further without editing the test.
The app keeps its state across cases, as a real server would: requests that create things create them, so later GETs see real data. Each request comes from its own peer address, so per-IP rate limits don't turn the run into a wall of 429s. Operations documented as WebSocket upgrades or server-sent event streams are skipped and listed in the report; an undocumented event stream is recognized by its text/event-stream content type and not read past its headers. After its first timeout an operation gets no further cases. Request schemas use the JSON Schema subset schemars emits; pattern and string formats are not checked by the validator (values for common formats such as email, uuid and date-time are still generated correctly).
Projects created with zapline new include this test as tests/fuzz.rs; zapline fuzz runs it and prints the report.
Lifecycle in tests
Startup hooks run on the first request. Use client.start().await to run them up front, client.resources().await to inspect state, and client.shutdown().await to run shutdown hooks. To swap real dependencies for fakes, register replacements before calling .test(). Registering the same type again replaces the earlier value:
#![allow(unused)] fn main() { let client = app().provide_dyn::<dyn UserRepo>(Arc::new(FakeRepo::default())).test(); }
Real sockets
When a feature needs a real connection (WebSockets, TLS, HTTP/2), serve on an ephemeral port:
#![allow(unused)] fn main() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; let addr = listener.local_addr()?; tokio::spawn(app().serve(listener)); // connect to `addr` with any client }
Snapshot tests from real traffic
Click through your app once and keep what happened as a regression test. In a debug build the dev console can record every request and the response it got; saved, they replay through app.test() on every cargo test.
-
Record. Start the app with
zapline dev --record(it setsZAPLINE_RECORD=1), or press Start recording at/_dev. Then use the app: a browser, curl, your frontend. -
Save. Run
zapline snap signup, or name the snapshot in the console and press Save as snapshot test. You gettests/snapshots/signup.jsonand, the first time,tests/snapshots.rs:#![allow(unused)] fn main() { #[tokio::test] async fn recorded_traffic() { my_app::app() .test() .replay_snapshots("tests/snapshots") .await; } } -
Run.
cargo testreplays every file intests/snapshots/in name order, each request in recorded order. A response must have the recorded status and body, or the test fails with a diff:1 of 5 recorded response(s) changed: tests/snapshots/signup.json #4 POST /users body (- recorded, + actual): { "code": "email_taken", - "error": "email already registered" + "error": "that email is taken" } If the new responses are right, accept them with ZAPLINE_UPDATE_SNAPSHOTS=1 cargo test -
Accept changes. When the new responses are right,
ZAPLINE_UPDATE_SNAPSHOTS=1 cargo test --test snapshotsrewrites the snapshot files that changed (and only those), each as the replay saw it: every exchange in an updated file is stored with the ids and values of this run, so the requests after a changed response still line up on the next run. Review the change in your diff like any other code.
What may differ between runs
Values that legitimately change are compared by kind, not by value, in JSON and in HTML or text bodies alike:
| Value | Example | Compared as |
|---|---|---|
| UUIDs | 67e55044-10b1-426f-9247-bb680e5fe0c8 | [uuid] |
| Timestamps | 2024-05-01T12:30:00Z, HTTP dates; any value under created_at, updatedAt, timestamp, exp... | [timestamp] |
| Tokens | session ids, API keys, JWTs, hex digests: long strings that mix letters and digits | [token] |
| Asset hashes | /static/app.73cd192d.css | app.[hash].css |
| Ids | any value under id, user_id, userId, uuid | [id] |
Ids also carry forward. When the recording's POST /users answered {"id": 7} and the replay's answers {"id": 1}, the requests after it are sent with 1 where the recording had 7: in path segments (/users/7 becomes /users/1), in id fields of the query, form or JSON body (user_id=7), and in the expected responses (an HTML page's hx-delete="/users/7"). Ids of three digits or more, UUIDs and tokens are replaced wherever they appear as a word; a bare 7 elsewhere is left alone, since it is more likely a count or a page number. So a session recorded against a database full of data replays against an empty test database.
Credentials and cookies
Authorization, Cookie, Proxy-Authorization, X-Api-Key, X-Auth-Token and CSRF token headers are never recorded: the snapshot says "[redacted]" and the replay does not send them (CSRF headers carry the replay's own token, see below). Each snapshot file replays with a fresh cookie jar, like a new browser, so a recorded login sets the session cookie that the following requests use. For bearer tokens and API keys, give the replay a test credential:
#![allow(unused)] fn main() { app().test() .replay_snapshots("tests/snapshots") .header("authorization", "Bearer test-token") .await; }
Secret-looking fields of the query, a JSON body (at any depth) or a urlencoded form are recorded as "[redacted]" too, in requests and responses: names containing password, secret, token, csrf, xsrf or api_key, or ending in _hash. So a login form, a reset link's ?token= and a JWT in a login response never land in a snapshot file. A redacted response value matches whatever the replay answers. To replay a recorded login, give the replay the value to send in place of each redacted field:
#![allow(unused)] fn main() { app().test() .replay_snapshots("tests/snapshots") .secret("password", "test-password") .await; }
CSRF tokens need nothing: a CSRF-protected app (Csrf) mints a new token for each replay's browser, so the replay sends the token from its CSRF cookie (a cookie whose name contains csrf or xsrf, such as zapline.csrf) in each recorded CSRF form field, JSON key and header (x-csrf-token, as htmx sends it), the way the page's form or script would.
Other parts of a body (HTML pages, multipart uploads, plain text) are recorded as they are: record with throwaway dev accounts.
What is recorded
The method, path, query, the headers that affect a response (accept, accept-language, content-type, host, origin, referer, sec-fetch-site, if-none-match, x-requested-with and htmx's hx-*), the body, the status, the content type and the response body. JSON bodies are stored as JSON, text as text and anything else as base64, so the files read well in a review.
Not recorded: the console's own requests, static files (CSS, scripts, images, fonts), WebSocket upgrades, and requests whose body is streamed or over 64 KiB. A response that is streamed (SSE), compressed or over 64 KiB is recorded without its body ("body_not_recorded"), and only its status is compared; an update run fills the body in.
Replay API
TestClient::replay_snapshots(path) takes a directory or a single file and returns a zapline::test::Replay:
| Call | Effect |
|---|---|
.await | Replay, and panic with every diff if anything changed |
.header(name, value) | Send this header with every request, replacing the recorded one |
.secret(name, value) | Send value for the redacted body or query field name |
.update(true) | Rewrite changed snapshots instead of failing (the default is ZAPLINE_UPDATE_SNAPSHOTS=1) |
.report().await | Return a ReplayReport (files, exchanges, updated, failures, passed()) instead of panicking |
Snapshots share the app instance (and its state) in a run. For a fresh app per snapshot, replay each file with its own client: app().test().replay_snapshots("tests/snapshots/signup.json").await.
Dev console
One call gives a running app a page that shows what it is made of and what it is doing:
use zapline::prelude::*; #[get("/")] async fn index() -> &'static str { "hi" } fn main() -> std::io::Result<()> { App::new() .dev_console() .mount(index) .run("127.0.0.1:8080") }
Open http://localhost:8080/_dev. Projects created with zapline new (except minimal) already have it.
| Panel | Shows |
|---|---|
| Recent errors | The last 20 errors (500s, panics and rejected requests), with their diagnostic code, the real message and the fix. |
| Recent requests | The last 50 requests: status (4xx and 5xx highlighted), method, path with query, and how long the app took. |
| Recording | Whether requests are being recorded, the latest recorded ones, and buttons to start or stop recording, Save as snapshot test and clear. See below. |
| Routes | Every route with its summary and tags (from doc comments and #[get(.., tag = "..")]). Parameterless GET routes are links. |
| Jobs | When durable jobs are registered: queued, running and failed counts, the registered job names, the latest failures with their errors, and a Retry failed jobs button. With the metrics plugin, per-job enqueued, succeeded, failed, retried and dead counts and the average run time; with cron jobs, each one's schedule and next enqueue. |
| Scheduled tasks | When the app has App::schedule jobs: each one's name, schedule (every 5m, cron 0 3 * * *), next run, run and failure counts, and how the last run went. |
When the mailer is the dev outbox: how many emails were kept, with a link to the inbox at /_dev/mail. |
Safe to leave in
- Debug builds only. In a release build (
cargo build --release, the Dockerfiles).dev_console()does nothing: no route, no middleware, no cost. - This machine only. Requests from any other address get a
404, so a debug build bound to0.0.0.0doesn't show its routes or jobs to the network.
The console's own requests are not listed under recent requests.
.dev_console() also turns on the dev error page: a 500 or a panic answers your browser with the error, its code, the fix and a Copy fix prompt block for your AI assistant.
JSON for tools
GET /_dev/api serves the same data as JSON, newest first, for scripts and for the live tools of zapline mcp:
{"requests":[{"method":"GET","path":"/orders","status":500,"micros":812,"code":"ZL0002"}],
"errors":[{"at_ms":1767225600000,"method":"GET","path":"/orders","status":500,"code":"ZL0002",
"error_code":"missing_dependency","message":"no provider registered for `app::Repo`",
"fix":".provide(value) // value: app::Repo; ..."}],
"routes":[{"method":"GET","path":"/orders"}]}
location (src/routes/orders.rs:42:17) is set for panics. Like the page, it answers only this machine.
Recording requests as tests
The console can record full request/response pairs and save them as snapshot tests that replay on every cargo test:
- Start recording with the button, or start the app with
ZAPLINE_RECORD=1(zapline dev --record). With the variable, recording starts with the app and is kept intarget/zapline/recording.jsonl, so the restartszapline devdoes after each change don't lose it. Recording started from the button lives in memory. - Save with the console's form or
zapline snap <name>: the recording goes totests/snapshots/<name>.json, plustests/snapshots.rs(which replays them) if the project has none. Saving empties the recording, so the next snapshot starts fresh. Authorization,Cookieand API key header values are never recorded, and neither are password, token, secret, CSRF and API key fields of queries, JSON bodies and forms (they are saved as"[redacted]"; see credentials). Static files, the console's own requests and WebSocket upgrades are skipped.
While not recording, the console's middleware checks one flag per request and does nothing else. In release builds there is no console and no recorder at all.
The console's routes for this, all local-only: GET /_dev/recordings returns the current recording as snapshot JSON; POST /_dev/recordings/start, /stop, /clear, and /save (form field name; answers JSON when the request accepts application/json, which is what zapline snap uses). The POST routes also refuse cross-site requests, so a web page can't drive them.
OpenAPI & interactive docs
Add one line, and your API documents itself: an OpenAPI 3.1 spec at /openapi.json plus Swagger UI at /docs.
use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use zapline::openapi::OpenApi; use zapline::prelude::*; #[derive(Serialize, Deserialize, JsonSchema)] struct Book { id: u64, title: String, } /// Get a book. /// /// Returns 404 when the book does not exist. #[get("/books/{id}", tag = "books")] async fn get_book(Path(id): Path<u64>) -> Option<Json<Book>> { (id == 1).then(|| Json(Book { id, title: "Dune".into() })) } fn main() -> std::io::Result<()> { App::new() .mount(get_book) .openapi(OpenApi::new("Bookshelf", "1.0.0")) .run("0.0.0.0:8080") }
The route macros read the doc comment and the handler signature (Path<T>, Query<T>, Json<T>, Option<T>, Result<T>, ...) and the plugin turns the route table into a spec. The spec is generated once, at startup, and served as static bytes, so documentation costs nothing per request.
Requirements:
-
Cargo features
openapi(on by default, impliesjson) andmacros(on by default) for the automatic parts. -
Types appear in the spec when they implement
JsonSchema.#[model]derives it for you (along with serde andValidate) with no extra dependency. The plain derive expands to paths in theschemarscrate, so add it to yourCargo.toml:schemars = "1"If you would rather not depend on
schemarsdirectly, point the derive at the re-export with#[schemars(crate = "zapline::openapi::schemars")](the trait iszapline::openapi::JsonSchema).
Types without JsonSchema still work as handler arguments and return values. They are simply not described.
What is described automatically
For routes registered with a macro (#[get], #[post], #[route], ...), the arguments and the return type are inspected:
| Handler type | Documented as |
|---|---|
Path<T> | Path parameters (a struct by field name, a tuple by position, or a scalar). Parameters that appear only in a nest prefix are included. |
Query<T> | One query parameter per field of T. Option<_> fields are optional. |
Json<T> | JSON request body, plus a 422 response |
Form<T> | application/x-www-form-urlencoded body, plus a 422 response |
Bytes, String | application/octet-stream / text/plain body |
Valid<E> | Whatever E documents, plus a 422 Validation Error response (see Validation) |
Option<E> (argument) | Makes E's parameters and body optional |
Json<T> (return) | application/json response of T |
String, &'static str | text/plain response |
Bytes, Vec<u8> | application/octet-stream response |
Html<T> | text/html response |
(), StatusCode | Response without a body |
Redirect | 303 with a Location header |
Option<T> (return) | T's response plus a 404 |
Result<T, E> (return) | T's response plus a default error response using the Error schema |
(StatusCode, T), ([headers; N], T) | T's response |
Operation IDs default to the handler's function name (builder routes get <method>_<path>, such as get_users_id). Duplicates get a suffix, so mounting the same handler twice is fine.
Macro arguments
Everything after the path in #[get("/path", ...)] documents the route. #[route] takes the same arguments plus method = "...".
| Argument | Example | Effect |
|---|---|---|
tag | tag = "users" | Adds a tag. Docs UIs group operations by tag. |
tags | tags("users", "admin") or tags = ["users", "admin"] | Adds several tags |
summary | summary = "Create a user" | One-line summary. Overrides the doc comment. |
description | description = "Longer text (CommonMark)" | Overrides the rest of the doc comment |
operation_id | operation_id = "createUser" | Explicit operationId. Must not be empty. |
status | status = 201 | Documents the success status. It does not change the real status. Return (StatusCode::CREATED, ...) yourself. |
deprecated | deprecated | Marks the operation deprecated |
hidden | hidden | Leaves the route out of the spec |
security | security = "bearer" or security("bearer", "api_key") | Requires a scheme declared on OpenApi. Several schemes are alternatives. |
Unknown arguments, duplicates (summary twice) and bad values are compile errors that list what is allowed.
#![allow(unused)] fn main() { use zapline::prelude::*; /// Create a user. /// /// The new user receives a welcome email. Emails must be **unique**. #[post("/users", tags("users", "admin"), status = 201, security = "bearer")] async fn create_user(Json(name): Json<String>) -> (StatusCode, Json<String>) { (StatusCode::CREATED, Json(name)) } #[get("/v1/users", deprecated, operation_id = "listUsersV1")] async fn old_list() -> Json<Vec<String>> { Json(vec![]) } #[get("/internal/debug", hidden)] async fn debug() -> &'static str { "ok" } }
Doc comments
The doc comment of a macro route becomes the operation text: the first paragraph is the summary (its lines are joined into one) and the rest is the description, rendered as CommonMark. summary = ".." and description = ".." take precedence. Doc comments on JsonSchema struct fields become property descriptions, too.
Routes without macros
Routes registered with .get(..), .post(..) and friends have no signature to probe, so describe them with builder methods. They apply to the most recently registered route, on both App and Routes, and they panic if no route has been registered yet.
| Method | Effect |
|---|---|
.summary(text) / .description(text) | Operation text |
.tag(name) | Adds a tag |
.operation_id(id) | Explicit operationId |
.deprecated() / .hidden() | Mark deprecated / leave out of the spec |
.security(scheme) | Requires a declared security scheme |
.response::<T>(status, description) | Documents a JSON response of type T |
.request_body::<T>() | Documents a JSON request body of type T |
.api_doc(|doc| { .. }) | Full access to the route's OperationDoc |
#![allow(unused)] fn main() { use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use zapline::prelude::*; #[derive(Deserialize, JsonSchema)] struct ReindexOptions { full: bool, } #[derive(Serialize, JsonSchema)] struct Report { documents: u64, } async fn reindex(Json(opts): Json<ReindexOptions>) -> Json<Report> { Json(Report { documents: if opts.full { 1000 } else { 10 } }) } fn app() -> App { App::new() .get("/health", || async { "ok" }) .summary("Liveness probe") .tag("ops") .post("/reindex", reindex) .summary("Rebuild the search index") .request_body::<ReindexOptions>() .response::<Report>(200, "The rebuild report") .api_doc(|doc| { doc.response_empty(202, "Rebuild queued") .error_response(409, "A rebuild is already running"); }) } }
OperationDoc has more methods for unusual routes: request_body_as::<T>(content_type), request_body_raw(content_type), success::<T>(content_type), success_raw(content_type), success_empty(), default_error(), redirect(), success_status(code), path_params::<T>(), query_params::<T>() and param::<T>(ParamIn::Header, "x-tenant", required).
Groups
Routes::tag_all and Routes::security_all set defaults for every route in a group, including routes added later. Route-level tags are added after the group's. A route's own security replaces the group's. When groups are nested, the innermost group that sets a default wins.
#![allow(unused)] fn main() { use zapline::prelude::*; fn admin() -> Routes { Routes::new() .get("/stats", || async { "stats" }) .summary("Admin statistics") .delete("/cache", || async { StatusCode::NO_CONTENT }) .tag_all("admin") .security_all("bearer") } fn app() -> App { App::new().nest("/admin", admin()) } }
The OpenApi plugin
OpenApi::new(title, version) is a plugin, installed with App::openapi(..). All other methods are optional:
| Method | Effect |
|---|---|
.description(text) | API description (CommonMark) |
.server(url) / .server_with(url, description) | Server URLs |
.contact(name, email) / .license(name) | info metadata |
.tag(name, description) | Describes a tag. Tags are listed in declaration order. |
.bearer_auth(name) / .basic_auth(name) / .api_key_auth(name, header) | Declares a security scheme |
.security_scheme(name, json) | Declares any scheme object (OAuth 2, OpenID Connect, ...) |
.security(name) | Requires a scheme for every operation that doesn't set its own |
.json_path(path) | Where the spec is served (default /openapi.json) |
.spec_url(url) | The URL docs pages load the spec from. Set it behind a path-rewriting proxy. |
.docs(DocsUi, path) | Serves a docs UI. The first call replaces the default (Swagger at /docs), further calls add more. |
.no_docs() | Serves only the JSON spec |
.cdn(base_url) | Loads UI assets from a mirror instead of jsDelivr |
.modify(|spec| { .. }) | Post-processes the generated serde_json::Value |
#![allow(unused)] fn main() { use zapline::openapi::{DocsUi, OpenApi}; use zapline::prelude::*; fn app() -> App { App::new().get("/", || async { "hi" }).openapi( OpenApi::new("My API", "1.2.0") .description("Everything about my service.") .server("https://api.example.com") .server_with("https://staging.example.com", "Staging") .tag("users", "User management") .bearer_auth("bearer") .docs(DocsUi::Swagger, "/docs") .docs(DocsUi::Scalar, "/reference") .docs(DocsUi::Redoc, "/redoc"), ) } }
The documentation routes themselves never appear in the spec.
Docs UIs
DocsUi | Looks like |
|---|---|
Swagger | Swagger UI. Lets you try requests in the browser. |
Scalar | Scalar. A modern reference with a built-in client. |
Redoc | Redoc. A clean three-panel reference. |
The pages are plain HTML rendered once at startup. Their assets load from jsDelivr with exact pinned versions and Subresource Integrity hashes, so a compromised CDN cannot inject code. In an air-gapped network, mirror the npm packages (the files must be byte-identical) and call .cdn("https://npm.internal.example").
Security schemes
Declare the schemes on the plugin, then opt routes in with security = "name" (macro), .security("name") (builder) or Routes::security_all("name") (group).
#![allow(unused)] fn main() { use zapline::openapi::OpenApi; use zapline::prelude::*; #[get("/me", security = "bearer")] async fn me() -> &'static str { "ada" } fn app() -> App { App::new().mount(me).openapi( OpenApi::new("Secure API", "1.0.0") .bearer_auth("bearer") // Authorization: Bearer <jwt> .api_key_auth("api_key", "x-api-key") // a header .basic_auth("basic"), ) } }
OpenApi::security(name) makes a scheme the default for all operations instead.
Documenting a scheme does not enforce anything, and enforcing something does not document it. Authentication policies such as .require_roles(..) (see Authentication & authorization) are separate from the security metadata: use both.
Schemas
Request bodies are described the way they deserialize and responses the way they serialize, because the two can differ (Option<T> fields are optional in a request but always present in a response, serde defaults, and so on). When the schemas of one type differ, the request side is published as <Name>Input, otherwise one component is shared. All named types go under components/schemas, and the framework's error body is published as Error.
#[derive(JsonSchema)] follows serde attributes (rename, default, skip, flatten, ...). It also recognizes validator rules, so #[validate(length(min = 1, max = 64))] becomes minLength / maxLength, range becomes minimum / maximum and email becomes format: email. See Validation.
Custom extractors and responses
A macro route documents an argument or return type when it implements ApiInput / ApiOutput. Implement them for your own types:
#![allow(unused)] fn main() { use zapline::extract::FromRequestParts; use zapline::openapi::{ApiInput, OperationDoc, ParamIn}; use zapline::prelude::*; struct TenantId(String); impl FromRequestParts for TenantId { async fn from_request_parts(req: &mut Request) -> Result<Self> { let value = req.headers().get("x-tenant").and_then(|v| v.to_str().ok()); value .map(|v| TenantId(v.to_owned())) .ok_or_else(|| Error::bad_request("missing x-tenant header")) } } impl ApiInput for TenantId { fn describe_input(op: &mut OperationDoc) { op.param::<String>(ParamIn::Header, "x-tenant", true); } } #[get("/reports")] async fn reports(TenantId(tenant): TenantId) -> String { tenant } }
The framework's own security extractors (BearerToken, Jwt, CurrentUser, ...) do not document themselves. Add security = "..." to the route.
Exporting the spec
Set ZAPLINE_EXPORT_OPENAPI to a file path and run your app. The plugin writes the spec as pretty-printed JSON and exits (status 0) from its build hook, before any port is bound and before any startup hook runs:
ZAPLINE_EXPORT_OPENAPI=openapi.json cargo run --bin my-api
The zapline openapi command does exactly this for you (see Project layout & the CLI). It is what you want in CI, to publish the spec or to generate client SDKs. Do not set the variable in a test environment: App::test() builds the app too, and the process would exit.
In tests, generate the spec without starting anything and assert on it:
#![allow(unused)] fn main() { use zapline::openapi::{spec_for, OpenApi}; use zapline::prelude::*; #[test] fn health_is_documented() { let app = App::new().get("/health", || async { "ok" }).summary("Liveness probe"); let spec = spec_for(app, OpenApi::new("Test", "1.0.0")); assert_eq!(spec["openapi"], "3.1.0"); assert_eq!(spec["paths"]["/health"]["get"]["summary"], "Liveness probe"); } }
zapline::openapi::export_spec(&spec, path) is the function behind the environment variable. An app asked for a spec without the OpenAPI plugin exits with code 78 instead of starting to serve.
Typed clients
The spec also drives typed clients for your frontend: a TypeScript file that zapline dev keeps current, and, with OpenApi::new(..).client_js(), an ES module with JSDoc types served at /_zapline/api.js for pages without a build step. zapline::openapi::codegen::{typescript, javascript} are the generators. See Typed frontends.
Limitations
- Only routes registered with a macro are documented from their signature. Builder routes need explicit
.response::<T>()/.request_body::<T>()calls. - A handler returning
impl IntoResponsegets a generic success response, because its type cannot be named. Document it with.response::<T>(..). Wrappers such asResult<impl IntoResponse>still get the error response. - Generic handler functions cannot be inspected. Their macro arguments still apply.
.any(..)routes and methods OpenAPI cannot express (CONNECT, custom methods) are left out.- Auth policies,
Multipart,Dep<T>,CurrentUser<U>and other extractors do not appear in the spec unless they implementApiInput. For uploads, use.api_doc(|d| { d.request_body_raw("multipart/form-data"); }). - An operation cannot opt out of a global
OpenApi::security(..)default. Use.modify(..)to set"security": []on a path yourself, or declare security per route or group instead. - Response headers are documented only for redirects.
- The docs pages need the browser to reach the CDN (or your mirror). The JSON spec does not.
Typed frontends (TypeScript & no-build JS)
Your Rust handlers are the API contract. zapline turns the OpenAPI document it already generates into a typed client, and keeps that client current while you work: change a struct, save, and the frontend's type checker points at every call that no longer fits. There is no schema language to maintain, no generator to install, and no npm package in between.
Two flavors come from the same document:
| You have | You get | How |
|---|---|---|
| A bundled frontend (React, Vue, Svelte, Solid...) | api.ts: interfaces plus a typed createClient() | zapline dev with a frontend/ directory, or zapline client -o web/src/api.ts |
Plain pages, htmx, <script type="module"> | api.js: the same client as an ES module with JSDoc types | OpenApi::new(..).client_js() serves /_zapline/api.js |
Both use fetch only, so they run in browsers, Node 18+, Deno and Bun, and both pass tsc --strict --exactOptionalPropertyTypes --noUncheckedIndexedAccess.
Live TypeScript with zapline dev
Put your frontend in frontend/ and run zapline dev. After every successful build, before the new binary starts, zapline exports the spec from it and writes frontend/src/api.ts. The file is only rewritten when it changes, so vite, tsc --watch and your editor react exactly once, and dev tells you what moved:
Building my-app
Client updated frontend/src/api.ts (+archiveTask ~createTask)
Running target/debug/my-app (pid 4242)
(+ added, - removed, ~ changed arguments or result.) A Rust change that breaks the frontend now shows up as a red squiggle in the .tsx file within a second of saving the .rs file.
To put the client somewhere else, or to also write the JavaScript module to disk, add a zapline.toml next to Cargo.toml (or the same keys under [package.metadata.zapline.client] in Cargo.toml):
[client]
ts = "web/src/api.ts" # TypeScript client
js = "static/js/api.js" # ES module with JSDoc types
# enabled = false # turn off the frontend/ default
zapline dev --client path/to/api.ts overrides the settings for one run and --no-client turns clients off. zapline client (no -o) writes the configured files once, which is what you want in CI. If the project has a frontend/ directory but the app has no OpenAPI plugin, dev says so once and carries on.
To fail CI when a committed client is stale, compare it with a fresh one in a Rust test:
use zapline::openapi::{codegen, spec_for};
#[test]
fn frontend_client_is_current() {
let spec = spec_for(my_app::app(), my_app::api());
let fresh = codegen::typescript(&spec).unwrap().source;
let committed = std::fs::read_to_string("frontend/src/api.ts").unwrap();
assert!(committed == fresh, "run `zapline client` and commit frontend/src/api.ts");
}
Using the client
Methods are named after operation ids (the handler name: create_task becomes createTask). Path parameters come first, then the body, then a query object, then per-call options:
import { api, createClient, isValidationError, type Task } from "./api";
const page = await api.listTasks({ status: "todo", limit: 20 }); // query is typed: status is "todo" | "doing" | "done"
const task: Task = await api.getTask(42);
await api.updateTask(42, { status: "done" });
await api.upload(42, { file: input.files![0]!, note: "scan" }); // multipart: a FormData or a plain object
const res = await api.events(); // SSE / NDJSON: the raw Response, to stream
const csv: Blob = await api.export(); // binary bodies resolve to a Blob
// Your own instance: base URL, auth headers, cookies, a custom fetch.
const remote = createClient({
baseUrl: "https://api.example.com",
headers: async () => ({ authorization: `Bearer ${await token()}` }),
credentials: "include",
});
await remote.getTask(1, { signal: AbortSignal.timeout(5000) });
api is a ready same-origin instance. Each method's doc comment carries the route (POST /tasks), the handler's doc comment, and the error responses it documents, so hovering a call shows what the server says about it. Field constraints from #[validate(..)] and #[serde(default)] appear on the interfaces as @minLength, @maximum, @default and so on.
What the generator maps:
| In Rust (via the spec) | In TypeScript |
|---|---|
structs, Option<T> fields | interfaces, field?: T | null |
| unit enums | string literal unions ("todo" | "doing" | "done"), with each variant's doc |
tagged enums (#[serde(tag = "kind")]) | discriminated unions: { kind: "circle"; radius: number } | { kind: "rect"; ... } |
HashMap<String, T>, tuples, Vec<T> | Record<string, T>, [A, B], T[] |
Path<(String, u32)>, {*rest} catch-alls | positional arguments; catch-alls keep their slashes |
Query<T> (also optional, also free-form maps) | one typed query object |
Json<T>, Form<T>, Multipart, String, Bytes bodies | JSON, URL-encoded, FormData, text and binary bodies |
Json<T>, String, Bytes, Sse, () / StatusCode results | T, string, Blob, Response, void |
Routes that answer with HTML (htmx pages and fragments) are left out.
Errors, typed
Every non-2xx response throws an ApiError with status, zapline's code and details, the decoded body, and the operation that failed. A 422 that lists field errors (from Valid<T>, or a body field of the wrong type) throws a ValidationError instead, whose fields are keyed by the request body's field names:
try {
await api.createTask(form);
} catch (e) {
if (isValidationError(e, "createTask")) {
e.fields.title?.[0]?.message; // "length must be between 1 and 80"; `title` autocompletes
e.messageFor("priority"); // the first message for a field, or undefined
e.errors; // every FieldError { field, code, message, params }
} else if (e instanceof ApiError && e.status === 404) {
// ...
}
}
isValidationError(e, "createTask") only matches errors thrown by that method, which is what lets TypeScript know the field names (they are listed per method in the exported ValidationFields interface). Nested fields use paths such as address.zip and items[0].name.
In a browser, unsafe requests (POST, PUT, PATCH, DELETE) copy zapline's CSRF cookie into the x-csrf-token header, so pages behind Csrf work without extra code. Pass csrf: { cookie, header } if you renamed them, or csrf: false.
No build step: /_zapline/api.js
For server-rendered pages, add .client_js() to the OpenAPI plugin:
App::new()
.mount((list_tasks, create_task))
.openapi(OpenApi::new("Tasks", "1.0.0").client_js())
The app then serves /_zapline/api.js, generated once at startup from the same spec (nothing per request, and nothing at all without .client_js()). It is public like /openapi.json, even behind Authenticate::required(). Use it from any page:
<script type="module">
import { api, isValidationError } from "/_zapline/api.js";
const form = document.querySelector("#new-task");
const title = form.querySelector("[name=title]");
title.addEventListener("input", () => title.setCustomValidity(""));
form.addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const task = await api.createTask({ title: title.value });
location.hash = `task-${task.id}`;
} catch (e) {
if (isValidationError(e, "createTask")) {
title.setCustomValidity(e.messageFor("title") ?? "");
form.reportValidity();
}
}
});
</script>
The module carries the same types as JSDoc (@typedef, @param, @returns) and starts with // @ts-check. Editors read JSDoc from files on disk, so for autocomplete in your own .js files also write a copy next to them with js = "static/js/api.js" in zapline.toml (or zapline client -o static/js/api.js) and import that path. With // @ts-check at the top of your script, VS Code then flags a wrong field name or a missing argument just as it would in TypeScript.
Limitations
- Error responses are typed as
ApiError/ValidationError; a handler's own errordetailsstayunknown. - Only the lowest documented 2xx response is typed. A handler that answers 200 or 201 with different bodies needs an explicit
.response::<T>(..)per status and gets the first. - Streaming responses (SSE, NDJSON) resolve to the raw
Response; for SSE in a browser,new EventSource(url)is usually simpler. - Editors cannot see a module that only exists at a URL:
/_zapline/api.jsgives a page the client at runtime, and the on-disk copy gives your editor the types.
Validation
Wrap an extractor in Valid and derive Validate on the type. Bad input never reaches your handler.
#![allow(unused)] fn main() { use serde::Deserialize; use validator::Validate; use zapline::prelude::*; use zapline::validate::Valid; #[derive(Deserialize, Validate)] struct NewUser { #[validate(length(min = 1, max = 64))] name: String, #[validate(email)] email: String, } #[post("/users", status = 201)] async fn create_user(Valid(Json(user)): Valid<Json<NewUser>>) -> StatusCode { println!("creating {} <{}>", user.name, user.email); StatusCode::CREATED } }
Requirements:
-
Cargo feature
validate(on by default, impliesjson). -
#[model]derivesValidate(plus serde andJsonSchema) without adding any crate. The plain derive expands to paths in thevalidatorcrate, so add it to yourCargo.toml:validator = { version = "0.21", features = ["derive"] }zapline::validatere-exports the crate (zapline::validate::validator, plusValidate,ValidationErrorandValidationErrors). To skip the direct dependency, put#[validate(crate = "zapline::validate::validator")]on the struct.
Which extractors
Valid<E> works with Json<T>, Form<T>, Query<T> and Path<T>, where T: Validate. It derefs to the wrapped extractor (which derefs to T) and destructures like the examples show.
| You write | Source | Position |
|---|---|---|
Valid<Json<T>> | JSON body | last argument |
Valid<Form<T>> | URL-encoded form body | last argument |
Valid<Query<T>> | query string | any |
Valid<Path<T>> | route parameters | any |
#![allow(unused)] fn main() { use serde::Deserialize; use validator::Validate; use zapline::prelude::*; use zapline::validate::Valid; #[derive(Deserialize, Validate)] struct Page { #[validate(range(min = 1, max = 100))] size: u32, #[validate(length(max = 10))] q: Option<String>, } #[derive(Deserialize, Validate)] struct Ids { #[validate(range(min = 1))] id: u64, } #[get("/things/{id}")] async fn list(Valid(Path(ids)): Valid<Path<Ids>>, page: Valid<Query<Page>>) -> String { // `page` derefs through `Valid` and `Query` to `Page`. format!("{}:{}", ids.id, page.size) } }
The wrapped extractor runs first, so its own failures are unchanged: a wrong content type is still a 415, an oversized body a 413, malformed JSON or a missing field a 422 (without validation details), and an unparsable query string or path parameter a 400. Only a value that deserializes but breaks a rule produces the validation error below.
The 422 response
A failed validation lists every broken rule, not just the first one:
{
"error": "validation failed",
"code": "validation_error",
"details": [
{ "code": "length", "field": "address.zip", "message": "zip must have 5 digits", "params": { "equal": 5 } },
{ "code": "range", "field": "age", "message": "must be at least 18 and at most 120", "params": { "max": 120, "min": 18 } },
{ "code": "no_spaces", "field": "contacts[1].handle", "message": "must not contain spaces", "params": {} },
{ "code": "length", "field": "contacts[1].name", "message": "length must be at least 1", "params": { "min": 1 } },
{ "code": "email", "field": "email", "message": "must be a valid email address", "params": {} },
{ "code": "length", "field": "password", "message": "must be at least 8 characters", "params": { "min": 8 } }
]
}
Each entry of details has:
| Key | Meaning |
|---|---|
field | Path to the field: email, address.zip, contacts[1].name. Empty for struct-level (schema) errors on the top-level value. |
code | The rule that failed: email, length, range, url, must_match, ... or the code of your custom validator |
message | The rule's message = "..", or a readable default such as length must be between 1 and 64 |
params | The rule's parameters ({"min": 8}). Never includes the rejected value. |
The list is sorted by field, so responses are stable. Rejected values are never echoed back, which keeps passwords and tokens out of error responses and logs.
details is the standard details member of a zapline error, so App::error_handler can re-render it, and the OpenAPI spec documents the 422 automatically.
Nested structs and lists
Add #[validate(nested)] to fields whose type also derives Validate. List items are reported with their index:
#![allow(unused)] fn main() { use serde::Deserialize; use validator::Validate; #[derive(Deserialize, Validate)] struct Address { #[validate(length(equal = 5, message = "zip must have 5 digits"))] zip: String, } #[derive(Deserialize, Validate)] struct Contact { #[validate(length(min = 1))] name: String, } #[derive(Deserialize, Validate)] struct Customer { #[validate(nested)] address: Address, // -> address.zip #[validate(nested)] contacts: Vec<Contact>, // -> contacts[1].name } }
validator records one kind of error per field. If a list breaks its own rule (#[validate(length(max = 2), nested)] with three items), you get that error first, and the item errors appear once it passes.
Built-in rules
These are validator 0.21's rules; see its documentation for all options.
| Rule | Example |
|---|---|
email, url, ip, non_control_character | #[validate(email)] |
length | #[validate(length(min = 1, max = 64))] or length(equal = 5). Works on strings, lists and maps. |
range | #[validate(range(min = 18, max = 120))], also exclusive_min / exclusive_max |
must_match | #[validate(must_match(other = "password"))] |
contains, does_not_contain | #[validate(contains(pattern = "@"))] |
regex | #[validate(regex(path = *USERNAME))] with a regex::Regex static |
required | #[validate(required)] on an Option<T> |
custom | #[validate(custom(function = "name"))] |
nested | #[validate(nested)] |
Every rule accepts message = ".." (shown to the client) and code = "..".
Custom validators
A field validator takes a reference to the value and returns Result<(), ValidationError>. The error code and message end up in the response:
#![allow(unused)] fn main() { use std::borrow::Cow; use serde::Deserialize; use validator::{Validate, ValidationError}; fn no_spaces(value: &str) -> Result<(), ValidationError> { if value.contains(' ') { return Err(ValidationError::new("no_spaces") .with_message(Cow::Borrowed("must not contain spaces"))); } Ok(()) } #[derive(Deserialize, Validate)] struct Handle { #[validate(custom(function = "no_spaces"))] handle: String, } }
Rules that involve several fields are struct-level validators. Their field is empty (or the parent's path when nested):
#![allow(unused)] fn main() { use std::borrow::Cow; use serde::Deserialize; use validator::{Validate, ValidationError}; fn check_total(order: &Totals) -> Result<(), ValidationError> { if order.a + order.b > 10 { return Err(ValidationError::new("total") .with_message(Cow::Borrowed("a + b must be <= 10"))); } Ok(()) } #[derive(Deserialize, Validate)] #[validate(schema(function = "check_total"))] struct Totals { a: u32, b: u32, } }
Validators here must be synchronous and pure. For checks that need the database ("is this email taken?"), do them in the handler and return Error::conflict(..).with_code("email_taken").
Validating by hand
Valid is a convenience around two public functions. Use them when the data doesn't come from an extractor, or to merge validation with your own checks:
#![allow(unused)] fn main() { use serde::Deserialize; use validator::Validate; use zapline::prelude::*; use zapline::validate::validation_error; #[derive(Deserialize, Validate)] struct Link { #[validate(url)] link: String, } async fn save(Json(body): Json<Link>) -> Result<String> { // A plain `?` on `ValidationErrors` would produce a 500, like any other // `std::error::Error`. `validation_error` builds the same 422 as `Valid`. body.validate().map_err(validation_error)?; Ok(body.link) } }
zapline::validate::field_errors(&errors) returns the flattened, sorted Vec<FieldError> if you want to build your own response.
HTML forms
A JSON 422 is right for an API client, but a browser that posts a form needs the form back, with a message next to each field. For pages, take ValidForm<T> (feature views) instead of Valid<Form<T>>: it runs the same rules but hands the outcome to the handler as a Result, and views.render_invalid re-renders the form as a 422 with {{ errors.field }} and {{ old.field }}:
use zapline::view::{context, ValidForm, Views};
#[post("/signup")]
async fn signup(views: Views, ValidForm(form): ValidForm<SignUp>) -> Result<Response> {
let form = match form {
Ok(form) => form,
Err(invalid) => return views.render_invalid("signup.html", context! {}, invalid),
};
// ...
Ok(Redirect::to("/").into_response())
}
The messages are the ones listed above; fields that don't parse at all get "must be a number" or "is required". Struct-level (schema) errors appear as {{ errors._form }}. FormErrors::from(&validation_errors) and FormErrors::from_error(&err) convert by hand. See Views: Forms.
OpenAPI
Valid<Json<T>> documents the request body exactly like Json<T> and adds a 422 Validation Error response. The #[validate(..)] rules are visible to #[derive(JsonSchema)], so length, range and email also appear in the schema as minLength, maximum, format: email and so on. See OpenAPI & interactive docs.
Dependency injection
Register a service once with App::provide, receive it in any handler with Dep<T>:
#![allow(unused)] fn main() { use zapline::di::Dep; use zapline::prelude::*; struct Greeter { greeting: String, } #[get("/hello/{name}")] async fn hello(greeter: Dep<Greeter>, Path(name): Path<String>) -> String { format!("{}, {name}!", greeter.greeting) } fn app() -> App { App::new() .provide(Greeter { greeting: "Hello".into() }) .mount(hello) } }
Providers are app-wide singletons stored behind an Arc. Dep<T> derefs to T, and extracting it costs one lookup plus a reference-count increment. T may be a trait object (Dep<dyn UserRepo>), which is what makes layers swappable in tests. Dep<T> is a parts extractor, so it can appear in any argument position.
If nothing provides T, the extractor fails with a 500 and the code missing_dependency.
Registering providers
| Method | What it registers |
|---|---|
provide(value) | A ready value of type T, wrapped in an Arc |
provide_arc(arc) | An Arc<T> you already hold, for example one shared with code outside the app |
provide_dyn::<dyn Trait>(arc) | A trait object, extracted as Dep<dyn Trait> |
provide_with(|res| async { Ok(value) }) | A value built asynchronously at startup (connect to a database, load a file) |
provide_dyn_with(|res| async { Ok(arc_dyn) }) | The same for a trait object: the factory returns Arc<dyn Trait> |
provide_injected::<T>() | A value built by T: Inject at startup |
override_provider::<T>(arc) | Replaces whatever provides T. Meant for tests. |
Registering the same type again replaces the earlier provider, including a factory that has not run yet. The last registration wins, whichever kind it was.
Layers: controller → service → repository
A Spring-style application keeps HTTP concerns in thin controllers, business rules in services, and storage behind a repository trait:
#![allow(unused)] fn main() { use std::sync::Arc; use zapline::di::Dep; use zapline::prelude::*; // Repository layer: a trait, so tests can swap in a mock. trait UserRepo: Send + Sync { fn name(&self, id: u64) -> Option<String>; } struct InMemoryUsers; impl UserRepo for InMemoryUsers { fn name(&self, id: u64) -> Option<String> { (id == 1).then(|| "ada".to_owned()) } } // Service layer: business rules; knows nothing about HTTP except `Error`. struct UserService { repo: Arc<dyn UserRepo>, } impl UserService { fn greeting(&self, id: u64) -> Result<String> { let name = self.repo.name(id).ok_or_else(Error::not_found)?; Ok(format!("hello, {name}")) } } // Controller layer: thin handlers. #[get("/users/{id}/greeting")] async fn greeting(users: Dep<UserService>, Path(id): Path<u64>) -> Result<String> { users.greeting(id) } fn app() -> App { App::new() .provide_dyn::<dyn UserRepo>(Arc::new(InMemoryUsers)) .provide_with(|res: Resources| async move { Ok(UserService { repo: res.dep::<dyn UserRepo>()? }) }) .mount(greeting) } }
The factory receives Resources, and res.dep::<T>() resolves a provider. Factories run at startup, in registration order (interleaved with on_startup hooks), before the server accepts connections:
- A factory can resolve every value registered with
provide,provide_arcorprovide_dyn(those are available immediately) and every factory registered before it. - A factory that fails aborts startup.
App::runreturns an error that names the type (dependency...failed to initialize: ...), and the port is never bound. - A factory returns
zapline::Result<T>, so?works on anything that converts intozapline::Error.
For a real database, the pool is just another factory:
#![allow(unused)] fn main() { use std::sync::Arc; use zapline::di::Dep; use zapline::prelude::*; struct Pool; impl Pool { async fn connect(_url: &str) -> Result<Pool> { Ok(Pool) } } struct OrderService { pool: Arc<Pool>, } fn app() -> App { App::new() .provide_with(|_res: Resources| async move { Pool::connect("postgres://localhost/app").await }) .provide_with(|res: Resources| async move { Ok(OrderService { pool: res.dep::<Pool>()? }) }) } }
To return a trait object from a factory, use provide_dyn_with. Annotate the Arc<dyn Trait> so the compiler knows which trait object you mean:
#![allow(unused)] fn main() { use std::sync::Arc; use zapline::prelude::*; trait Mailer: Send + Sync { fn send(&self, to: &str, body: &str); } struct SmtpMailer; impl Mailer for SmtpMailer { fn send(&self, _to: &str, _body: &str) {} } fn app() -> App { App::new().provide_dyn_with(|_res: Resources| async move { let mailer: Arc<dyn Mailer> = Arc::new(SmtpMailer); // connect, authenticate, ... Ok(mailer) }) } }
Injecting by trait: Inject
When a service just collects other providers, implement Inject instead of writing a closure. The method is synchronous and runs at startup:
#![allow(unused)] fn main() { use std::sync::Arc; use zapline::di::Inject; use zapline::prelude::*; struct Db; struct Repo { db: Arc<Db>, } impl Inject for Repo { fn inject(res: &Resources) -> Result<Self> { Ok(Repo { db: res.dep::<Db>()? }) } } fn app() -> App { App::new().provide(Db).provide_injected::<Repo>() } }
Providers outside handlers
Resources::dep::<T>() resolves a provider anywhere you have Resources: startup hooks, shutdown hooks, scheduled jobs, background workers (see Background tasks) and plugins. In middleware, use req.state::<Arc<T>>().
Overriding providers in tests
Build the real app, then replace the pieces you don't want. Because the last registration wins, even a factory that has not run yet is replaced, and it never runs:
#![allow(unused)] fn main() { use std::sync::Arc; use zapline::prelude::*; trait UserRepo: Send + Sync { fn name(&self, id: u64) -> Option<String>; } struct MockRepo; impl UserRepo for MockRepo { fn name(&self, _id: u64) -> Option<String> { Some("mock".to_owned()) } } fn app() -> App { // The application, as built in production (see above). App::new().get("/users/{id}", |repo: zapline::di::Dep<dyn UserRepo>, Path(id): Path<u64>| async move { repo.name(id).ok_or_else(Error::not_found) }) } #[tokio::test] async fn greets_with_a_mock_repository() { let client = app() .override_provider::<dyn UserRepo>(Arc::new(MockRepo)) .test(); let res = client.get("/users/7").send().await; assert_eq!(res.text(), "mock"); } }
override_provider is provide_arc with a name that says what you mean. See Testing for the test client.
Request scope: Cached<T>
Providers are singletons. For a value that is computed per request (the current user, the tenant, a database transaction), implement FromRequestParts for it and extract it as Cached<T>. The first extraction runs T's extractor and stores the result in the request; later ones in the same request (in route middleware, then in the handler) get a clone:
#![allow(unused)] fn main() { use zapline::di::Cached; use zapline::extract::FromRequestParts; use zapline::prelude::*; #[derive(Clone)] struct Tenant(String); impl FromRequestParts for Tenant { async fn from_request_parts(req: &mut Request) -> Result<Self> { // Imagine a database lookup here: it runs once per request. let id = req.headers().get("x-tenant").and_then(|v| v.to_str().ok()); id.map(|id| Tenant(id.to_owned())) .ok_or_else(|| Error::bad_request("missing x-tenant header")) } } /// Route middleware: reject unknown tenants early. The handler reuses the lookup. async fn require_tenant(mut req: Request, next: Next) -> Response { match Cached::<Tenant>::resolve(&mut req).await { Ok(_) => next.run(req).await, Err(e) => e.into_response(), } } async fn dashboard(Cached(tenant): Cached<Tenant>) -> String { format!("dashboard of {}", tenant.0) } fn app() -> App { App::new().get("/dashboard", dashboard).wrap_route(require_tenant) } }
T must be FromRequestParts + Clone + Sync + 'static.
Dep<T>, State<T> and Resources
All three read from the same typed registry (Resources), keyed by type:
| Registered with | Extracted as | Notes | |
|---|---|---|---|
| Plain state | App::state(value), or res.insert(value) in a hook | State<T> | Cloned per request, so T: Clone. Fine for small configuration or handles that are already cheap to clone. |
| Provider | App::provide* | Dep<T> | Held as Arc<T>; T may be unsized (dyn Trait). Built by factories and replaceable in tests. |
| Typed settings | App::config(settings) | State<T> and Dep<T> | See Configuration |
A provider is stored under the key Arc<T>, which is why State<Arc<T>> reaches it too. Prefer Dep<T> for services and trait objects, State<T> for small Clone values, and Resources when you are outside a request.
Configuration
Load layered settings into your own struct, then hand it to the app:
use serde::Deserialize; use zapline::config::{Config, Secret}; use zapline::di::Dep; use zapline::prelude::*; #[derive(Clone, Deserialize)] struct Settings { server: Server, database: Database, } #[derive(Clone, Deserialize)] struct Server { host: String, port: u16, } #[derive(Clone, Deserialize)] struct Database { url: Secret<String>, // never printed by Debug/Display pool: u32, } async fn info(settings: Dep<Settings>) -> String { format!("pool size {}", settings.database.pool) } fn main() -> std::io::Result<()> { let settings: Settings = Config::load()?.extract()?; let addr = (settings.server.host.clone(), settings.server.port); App::new() .config(settings) // State<Settings> and Dep<Settings> .get("/info", info) .run(addr) }
Requires the Cargo feature config (on by default; it adds the toml crate). ConfigError converts into std::io::Error and into zapline::Error, so ? works in main and in handlers.
Layers
Config::load() merges these layers, lowest precedence first:
| Layer | Example |
|---|---|
config/default.toml (optional) | [database]url = "postgres://localhost/app" |
config/{profile}.toml (optional) | config/production.toml |
.env (optional) | APP__DATABASE__POOL=20. It never overrides variables that are really set. |
Environment variables APP__* | APP__DATABASE__URL → database.url |
Tables are deep-merged: a later layer overrides single keys and keeps the siblings. A production.toml that only sets [database] url leaves database.pool from default.toml alone.
# config/default.toml
[server]
host = "127.0.0.1"
port = 8080
[database]
url = "postgres://localhost/app"
pool = 5
# config/production.toml
[server]
host = "0.0.0.0"
[database]
pool = 20
APP_ENV=production APP__DATABASE__URL=postgres://db.internal/app ./my-app
Profiles
The active profile is, in order: the builder's .profile(..), the APP_ENV environment variable, ZAPLINE_ENV, and finally development. An empty variable counts as unset. APP_ENV follows the configured prefix, so with .env_prefix("MYAPP") it is MYAPP_ENV. Both can also be set in the .env file. config.profile() returns the name, which you can use to switch behavior (for example, to require TLS in production).
Profile names may only contain ASCII letters, digits, - and _, so APP_ENV=../../etc can never reach the file system. Anything else is a ConfigError::InvalidProfile.
Environment variables
Names are PREFIX, then the path segments, all joined by the separator (default __). Segments are lowercased, and an existing key with different casing matches case-insensitively:
| Variable | Sets |
|---|---|
APP__SERVER__PORT=9000 | server.port |
APP__DATABASE__MAX_CONNECTIONS=50 | database.max_connections |
Values are interpreted leniently, so you never need to quote anything in a shell:
- An environment value becomes a TOML scalar or array only if that round-trips exactly (
8080,true,1.5,[1, 2]). Otherwise it stays a string. So007,0x1Fand1.10are kept as written. - Typed extraction is lenient where environment variables need it:
"8080"reads as a number,8080as a string,"a,b"as a list, andyes,on,1astrue. - Field and enum variant names match case-insensitively when there is no exact match.
.env files
KEY=value, export KEY=value, # comments, inline comments after unquoted values ( # ...), 'single' quotes (literal), and "double" quotes with \n \r \t \" \\ \$ escapes. Quoted values may span lines. Variables are not expanded (${VAR} stays literal). Values from .env feed the environment layer and profile selection, but are never exported into the process, and real environment variables always win. A malformed file is an error that names the line.
The builder
Config::load() is Config::builder().standard().build(). Use the builder for anything else:
#![allow(unused)] fn main() { use zapline::config::Config; fn load() -> Result<Config, zapline::config::ConfigError> { Config::builder() .toml_str("[server]\nport = 8080") // embedded defaults .file_optional("config/default.toml") .file_optional("config/{profile}.toml") // {profile} is replaced .dotenv(".env") .env_prefix("MYAPP") // MYAPP__SERVER__PORT .set_default("server.workers", 4) // below every other layer .set("server.tls", false) // above every other layer .build() } }
Layers merge in this order: set_default values, then files and TOML strings in the order added, then the environment (including .env), then set overrides.
| Method | Effect |
|---|---|
standard() | The layers Config::load uses: config/default.toml, config/{profile}.toml, .env and the APP prefix. It keeps a prefix or .env path you already set. |
file(path) | A required TOML file. A missing file is an error. {profile} in the path is replaced. |
file_optional(path) | A TOML file that is skipped when it doesn't exist |
toml_str(text) | An inline TOML document, for embedded defaults |
env_prefix(prefix) | Enables the environment layer: PREFIX__A__B=v sets a.b. Also selects the PREFIX_ENV variable for the profile. |
separator(sep) | Nesting separator in variable names (default __). Panics if empty. |
dotenv(path) | Reads variables from a .env file (skipped when missing) |
env_source(vars) | Uses these (name, value) pairs instead of the process environment. For tests and sandboxes. |
profile(name) | Forces the profile |
base_dir(dir) | Resolves relative file and .env paths against dir instead of the working directory |
set(key, value) | Overrides a dotted key with any serializable value. Highest precedence. |
set_default(key, value) | A fallback below all other layers |
build() | Reads and merges everything |
env_source makes configuration trivially testable, without touching real environment variables:
#![allow(unused)] fn main() { use zapline::config::Config; #[test] fn environment_overrides_files() { let config = Config::builder() .toml_str("[database]\nurl = \"postgres://localhost/dev\"\npool = 5") .env_prefix("MYAPP") .env_source([("MYAPP__DATABASE__POOL", "20")]) .build() .unwrap(); assert_eq!(config.get::<u32>("database.pool").unwrap(), 20); } }
Reading values
| Method | Returns |
|---|---|
extract::<T>() | The whole tree deserialized into T |
get::<T>("a.b") | One value. Keys are dotted, with servers[0].host or servers.0.host for arrays. A missing key is an error unless T is an Option. |
get_or(key, default) | default when the key is missing. A present but invalid value is still an error. |
contains(key) / value(key) / table() | Presence check, the raw toml::Value, the whole merged table |
origin(key) | Which layer set a key, for diagnostics: env `APP__SERVER__PORT` , file `config/production.toml` |
profile() | The active profile |
#![allow(unused)] fn main() { use zapline::config::Config; fn read(config: &Config) -> Result<(), zapline::config::ConfigError> { let port: u16 = config.get("server.port")?; let workers: usize = config.get_or("server.workers", 4)?; let first_host: String = config.get("hosts[0]")?; let timeout: Option<u64> = config.get("server.timeout_secs")?; println!("{port} {workers} {first_host} {timeout:?}"); Ok(()) } }
Use #[serde(default)] and #[serde(deny_unknown_fields)] on your settings structs as usual. The latter turns a typo such as prot = 8080 into an error that names the key.
Using settings in the app
App::config(settings) registers the value as both State<T> and Dep<T> (it needs T: Clone + Send + Sync + 'static). Pass it to services in provider factories with res.dep::<Settings>()?:
#![allow(unused)] fn main() { use std::sync::Arc; use serde::Deserialize; use zapline::prelude::*; #[derive(Clone, Deserialize)] struct Settings { greeting: String, } struct Greeter { greeting: String, } fn app(settings: Settings) -> App { App::new() .config(settings) .provide_with(|res: Resources| async move { let settings: Arc<Settings> = res.dep::<Settings>()?; Ok(Greeter { greeting: settings.greeting.clone() }) }) } }
See Dependency injection for how factories work. You can also register the Config itself with .state(config) to read dynamic keys in handlers.
Secrets
Secret<T> wraps a value that must not end up in logs. Its Debug and Display print [REDACTED], it deserializes transparently (a Secret<String> field reads a plain string), and it is not Serialize, so it cannot be returned from a handler by accident. Read the value with .expose(). The method name is easy to grep for:
#![allow(unused)] fn main() { use zapline::config::Secret; fn demo() { let key = Secret::new("hunter2".to_string()); assert_eq!(format!("{key:?}"), "[REDACTED]"); assert_eq!(key.expose(), "hunter2"); } }
Config's own Debug output lists only the profile and the top-level keys, never values.
Inject secrets through environment variables (APP__DATABASE__URL=...) rather than committing them to config/*.toml.
Error messages
Configuration errors name the key, the expected type and the layer that set the value:
invalid config key `server.port`: invalid value: string "eighty", expected u16 (set by env `APP__SERVER__PORT`)
invalid config key `server.port`: invalid value: integer `70000`, expected u16 (set by string #1)
missing config key `database.url`
invalid config key `s.prot`: unknown key, expected one of `port` (set by string #1)
config file `config/nope.toml`: No such file or directory (os error 2)
config file `config/bad.toml` is invalid: TOML parse error at line 2, column 8 ...
config file `.env` is invalid: line 2: expected `KEY=value`
invalid config profile "pro d": use ASCII letters, digits, `-` and `_`
ConfigError is an enum (Io, Parse, Missing, Invalid, InvalidProfile) with .key() for the key path. When one reaches a request handler through ? it becomes a 500 whose details stay in the server log, so a bad configuration value is never shown to clients.
Background tasks & scheduled jobs
Queue work from a handler with BackgroundTasks, run periodic or cron jobs with App::schedule, and run long-lived workers with App::background. All three are stopped cleanly on shutdown.
#![allow(unused)] fn main() { use zapline::prelude::*; use zapline::tasks::BackgroundTasks; async fn send_welcome_email(address: String) -> Result<()> { // talk to the mail server... Ok(()) } #[post("/signup")] async fn signup(tasks: BackgroundTasks, Json(address): Json<String>) -> StatusCode { tasks.spawn(send_welcome_email(address)); // runs after this handler returns StatusCode::CREATED } }
zapline::tasks is always compiled in; there is no Cargo feature to enable.
BackgroundTasks
BackgroundTasks is an extractor holding a queue. tasks.spawn(future) adds to it, and the queued futures run after the handler returns:
- They run in order, one after another, on the current runtime. The response doesn't wait for them.
- They start when the extractor (and every clone of it) is dropped, which normally happens when the handler function finishes. This is whatever the handler returns, an error included. Queue tasks after the fallible work, or call
tasks.clear()on failure. - A task may return
()orResult<(), E>whereE: Display. Errors and panics are logged (throughtracingwhen enabled, otherwise on stderr) and never affect the client or the server. - A task must be
Send + 'static.Dep<T>is anArcunderneath, so clone it into the task. - If the app has a job, a worker, or sets
App::tasks_grace_period, shutdown waits for queued tasks (see Shutdown and the grace period). Without any of these, tasks still queued when the process exits can be cut off. - Tasks live in memory. If the process crashes or is killed before they run, they are lost. For work that must not be lost, use durable jobs, which are stored in the database and retried.
#![allow(unused)] fn main() { use zapline::di::Dep; use zapline::prelude::*; use zapline::tasks::BackgroundTasks; struct Mailer; impl Mailer { async fn send_receipt(&self, order_id: u64) -> Result<()> { println!("receipt for order {order_id}"); Ok(()) } } async fn save_order(items: Vec<String>) -> Result<u64> { Ok(items.len() as u64) } #[post("/orders")] async fn create_order( tasks: BackgroundTasks, mailer: Dep<Mailer>, Json(items): Json<Vec<String>>, ) -> Result<(StatusCode, Json<u64>)> { let id = save_order(items).await?; // fallible work first let mailer = mailer.clone(); tasks.spawn(async move { mailer.send_receipt(id).await }); Ok((StatusCode::CREATED, Json(id))) } }
| Method | Effect |
|---|---|
spawn(future) | Queues a task |
clear() | Drops every queued task |
len() / is_empty() | Inspect the queue |
BackgroundTasks::new() | A queue that isn't tied to a request. Its tasks run when it is dropped inside a Tokio runtime. |
Scheduled jobs
App::schedule(schedule, job) runs a closure periodically for the lifetime of the app. The closure receives Resources (use res.dep::<T>() to reach your providers) and returns a future producing () or Result<(), E: Display>:
#![allow(unused)] fn main() { use std::time::Duration; use zapline::prelude::*; use zapline::tasks::Schedule; fn app() -> App { App::new() .schedule( Schedule::every(Duration::from_secs(300)).name("refresh-cache"), |_res: Resources| async move { // refresh a cache; returning Err(..) is logged and the schedule goes on Ok::<_, Error>(()) }, ) .schedule( Schedule::cron("0 9 * * MON-FRI").name("standup-digest"), // 09:00 UTC on weekdays |_res: Resources| async move { println!("sending the digest"); }, ) .schedule( Schedule::daily_at(3, 30).name("nightly-report"), // 03:30 UTC |_res: Resources| async move { println!("building the nightly report"); }, ) } }
| Schedule | Meaning |
|---|---|
Schedule::every(period) | Every period, starting immediately at startup. It is fixed-rate: a run that overruns skips the missed ticks instead of bunching them up. Panics if period is zero. |
Schedule::cron(expr) | Whenever the cron expression matches: 5 or 6 fields, ranges, steps, names, L/W/#, @hourly... in UTC, or in the zone given by a CRON_TZ= prefix. It follows the wall clock; a run that overruns skips the matches it missed. Panics on an invalid expression (pass a Cron you parsed to handle the error). |
Schedule::daily_at(hour, minute) | Once a day at that time in UTC: the same as Schedule::cron("{minute} {hour} * * *"). Panics on an invalid time. |
Refine a schedule with modifiers:
| Modifier | Effect |
|---|---|
.delay_start(duration) | Waits this long after startup before the first run (for cron and daily_at, the first run is the next occurrence after the delay) |
.allow_overlap() | Starts a new run even if the previous one is still running. By default runs never overlap. |
.limit(runs) | Stops after runs runs |
.name(text) | A name for log messages, metrics and the dev console. Without one, jobs are called job #1, job #2, ... |
Things to know:
- A job starts from a startup hook registered at that point in the builder chain, so it can use every provider registered before it.
- Errors and panics are logged, and the schedule continues.
- Jobs and workers run on the app's small "system" runtime (two threads, the one that runs startup hooks), not on the per-core request runtimes. A busy job can't stall a request's core, but it shares those two threads with every other job, so move blocking or CPU-heavy work to
tokio::task::spawn_blocking. - Each running instance of your app runs its own schedule. With several replicas, a job runs once per replica. For work that must run once per tick across the cluster, use a cron job instead:
.jobs(|q| q.cron("0 3 * * *", NightlyReport {})). schedule.next_after(time)andschedule.delay_until_next(time)tell you when a schedule fires next, without running it.
With the Metrics plugin installed, every run is counted and timed, labelled by the schedule's name (task). Without the plugin nothing is recorded.
| Series | Type | Labels |
|---|---|---|
scheduled_task_runs_total | counter | task, outcome (ok, error, panic) |
scheduled_task_duration_seconds | histogram (5 ms to 15 min) | task |
In debug builds the dev console lists every schedule with its next run, run and failure counts, and how the last run went.
Long-running workers
App::background(worker) runs an async closure once, for the lifetime of the app: a queue consumer, a cache refresher, a websocket client. It receives Resources and a Shutdown signal and should return when the signal fires:
#![allow(unused)] fn main() { use std::time::Duration; use zapline::prelude::*; use zapline::tasks::Shutdown; fn app() -> App { App::new().background(|_res: Resources, shutdown: Shutdown| async move { loop { tokio::select! { _ = shutdown.wait() => break, _ = tokio::time::sleep(Duration::from_millis(500)) => { // poll a queue, flush a buffer... } } } }) } }
A worker that returns an error or panics is logged and not restarted. Wrap the loop yourself if you want retries.
Shutdown
Shutdown is a cloneable signal:
| Method | Effect |
|---|---|
wait().await | Resolves once shutdown has started (immediately if it already has). Cancel-safe, so it works inside tokio::select!. |
is_shutdown() | Non-blocking check |
Shutdown::channel() | Creates a (ShutdownTrigger, Shutdown) pair, to drive workers in unit tests. trigger.trigger() fires it. |
As soon as the app has a job, a worker, or a custom grace period, Shutdown is also registered as state, so handlers can react to it. For example, to end a long poll early instead of holding up the drain:
#![allow(unused)] fn main() { use std::time::Duration; use zapline::prelude::*; use zapline::tasks::Shutdown; async fn poll(State(shutdown): State<Shutdown>) -> &'static str { tokio::select! { _ = shutdown.wait() => "shutting down, retry elsewhere", _ = tokio::time::sleep(Duration::from_secs(30)) => "nothing new", } } fn app() -> App { App::new().tasks_grace_period(Duration::from_secs(10)).get("/poll", poll) } }
Testing a worker needs no app at all:
#![allow(unused)] fn main() { use std::time::Duration; use zapline::tasks::Shutdown; async fn worker(shutdown: Shutdown) -> u32 { let mut ticks = 0; loop { tokio::select! { _ = shutdown.wait() => return ticks, _ = tokio::time::sleep(Duration::from_millis(5)) => ticks += 1, } } } #[tokio::test] async fn worker_stops_when_signalled() { let (trigger, shutdown) = Shutdown::channel(); let handle = tokio::spawn(worker(shutdown)); tokio::time::sleep(Duration::from_millis(30)).await; trigger.trigger(); assert!(handle.await.unwrap() > 0); } }
Shutdown and the grace period
When the server stops, shutdown hooks run after the connections have drained (see State, lifecycle & plugins). The task supervisor then:
- Fires
Shutdown, which stops schedules and tells workers to return. - Waits for workers, queued
BackgroundTasksand overlapping job runs to finish. - After the grace period, aborts whatever is still running and logs a warning.
The grace period defaults to 10 seconds (zapline::tasks::DEFAULT_GRACE_PERIOD). Change it with App::tasks_grace_period(Duration).
The grace period comes on top of the time the server spends draining connections (see Server: TLS, HTTP/2, graceful shutdown). Size your orchestrator's termination timeout for both; see Deployment.
Tests
App::test() starts jobs and workers together with the other startup hooks, on the first request. Call client.start().await to start them eagerly and client.shutdown().await to stop them and wait for the drain. Scheduled jobs with long periods never fire inside a short test, so test the job's logic as a plain function and keep the schedule thin.
Cron schedules
Cron expressions say when something runs. zapline uses them in two places:
| Runs | Use it for | |
|---|---|---|
App::schedule(Schedule::cron(expr), job) (tasks) | in every instance of the app, in memory | refreshing a local cache, per-instance housekeeping |
.jobs(|q| q.cron(expr, job)) (durable jobs) | once per tick for the whole cluster, stored and retried | reports, billing runs, emails, anything that must happen exactly once per tick |
#![allow(unused)] fn main() { use zapline::db::Database; use zapline::jobs::Job; use zapline::prelude::*; use zapline::tasks::Schedule; #[model] struct InvoiceRun {} impl Job for InvoiceRun { const NAME: &'static str = "invoice_run"; async fn run(self, _res: Resources) -> Result<()> { Ok(()) } } fn app() -> App { App::new() .database(Database::from_env()) // Each instance: every 5 minutes during office hours, UTC. .schedule(Schedule::cron("*/5 8-18 * * MON-FRI").name("warm-cache"), |_res: Resources| async move { // ... }) // The cluster: 02:00 Paris time on the 1st, enqueued once. .jobs(|q| q.cron("CRON_TZ=Europe/Paris 0 2 1 * *", InvoiceRun {})) } }
zapline::cron is always compiled in. Only IANA time zone names (Europe/Paris) need the tz feature.
Syntax
Five fields, or six with seconds first:
┌───────────── second (0-59) only in the 6-field form
│ ┌─────────── minute (0-59)
│ │ ┌───────── hour (0-23)
│ │ │ ┌─────── day of month (1-31)
│ │ │ │ ┌───── month (1-12 or JAN-DEC)
│ │ │ │ │ ┌─── day of week (0-7 or SUN-SAT; 0 and 7 are Sunday)
│ │ │ │ │ │
0 30 9 * * MON-FRI 09:30:00 on weekdays
30 9 * * MON-FRI the same, 5 fields
Each field is a comma-separated list of:
| Item | Meaning | Example |
|---|---|---|
* | every value | * * * * * every minute |
n | one value | 0 12 * * * at noon |
a-b | a range, which may wrap around | 0 22-2 * * * 22:00 to 02:00; FRI-MON |
*/n | every n-th value | */15 * * * * every 15 minutes |
a-b/n | every n-th value in a range | 0 9-17/2 * * * 09:00, 11:00, ... 17:00 |
a/n | every n-th value from a | 5/20 * * * * minutes 5, 25, 45 |
| names | months and weekdays, any case | 0 0 1 JAN,jul * |
Extensions in the day fields (from Quartz):
| Item | Field | Meaning |
|---|---|---|
? | either day field | same as * |
L | day of month | the last day of the month (28th to 31st) |
L-n | day of month | n days before the last day: L-2 |
nW | day of month | the weekday nearest the n-th, inside the month: 15W |
LW | day of month | the last weekday of the month |
d#n | day of week | the n-th such weekday of the month: FRI#2, 1#1 |
dL | day of week | the last such weekday of the month: 5L, SUNL |
When both day fields are restricted (neither starts with * or ?), a day matches if either matches, as in classic cron: 0 0 1 * MON runs on the 1st and every Monday. If one of them starts with * or ?, both must match: 0 0 */2 * MON runs on Mondays that fall on an odd date.
Macros:
| Macro | Same as |
|---|---|
@yearly, @annually | 0 0 1 1 * |
@monthly | 0 0 1 * * |
@weekly | 0 0 * * 0 |
@daily, @midnight | 0 0 * * * |
@hourly | 0 * * * * |
Expressions are checked when the app is built: a typo panics with the reason (invalid cron expression `0 25 * * *`: hour `25` is out of range 0-23), and so does an expression that can never match, such as 0 0 30 2 *. To handle errors yourself, for example for a schedule read from configuration, parse it first:
#![allow(unused)] fn main() { use zapline::cron::Cron; use zapline::tasks::Schedule; fn schedule_from_config(expr: &str) -> Result<Schedule, zapline::cron::CronError> { let cron: Cron = expr.parse()?; Ok(Schedule::cron(cron)) } }
Time zones
An expression runs in UTC unless it starts with CRON_TZ=<zone> (or TZ=<zone>):
| Zone | Example | Needs |
|---|---|---|
| UTC | CRON_TZ=UTC, Z, GMT | nothing |
| A fixed offset | CRON_TZ=+05:30, -0800, UTC+2 | nothing |
| An IANA zone | CRON_TZ=America/New_York | the tz feature |
zapline = { version = "0.1", features = ["tz"] }
With tz, zone rules come from the system's time zone database (/usr/share/zoneinfo on Linux and macOS; bundled on Windows), through the jiff crate. Without it, an IANA name is an error that says to enable the feature. You can also set the zone in code with Cron::with_timezone(Tz::parse("Europe/Paris")?).
Fields are matched against the local time: CRON_TZ=+05:30 0 1 * * MON runs at 01:00 on Mondays in that zone, which is Sunday 19:30 UTC.
Daylight-saving time
Wall clocks skip an hour in spring and repeat one in autumn. zapline handles both without surprises:
- Clocks go forward (02:00 jumps to 03:00): a time that doesn't exist that day, such as
30 2 * * *, runs once, at the jump (03:00 local). A schedule with several times in the missing hour also runs just once there. - Clocks go back (02:00 returns to 01:00): a time that happens twice, such as
30 1 * * *, runs once, the first time. A schedule whose hour field covers every hour, such as*/15 * * * *, keeps its rhythm through both passes, so it never pauses for an hour.
UTC and fixed offsets have no daylight-saving changes, which is why they are the default.
Inspecting a schedule
Cron computes fire times without running anything, which is handy in tests and admin pages:
#![allow(unused)] fn main() { use std::time::{Duration, UNIX_EPOCH}; use zapline::cron::Cron; let cron = Cron::parse("0 0 L * *")?; // midnight on the last day of each month let jan_1_2024 = UNIX_EPOCH + Duration::from_secs(1_704_067_200); let next: Vec<_> = cron.upcoming(jan_1_2024).take(2).collect(); assert_eq!(next[0], UNIX_EPOCH + Duration::from_secs(1_706_659_200)); // 2024-01-31 assert_eq!(next[1], UNIX_EPOCH + Duration::from_secs(1_709_164_800)); // 2024-02-29 Ok::<(), zapline::cron::CronError>(()) }
| Method | Returns |
|---|---|
Cron::parse(expr) / expr.parse::<Cron>() | the parsed expression or a CronError |
cron.next_after(t) | the first fire time strictly after t (whole seconds) |
cron.upcoming(t) | every fire time after t, as an iterator |
cron.with_timezone(tz) / cron.timezone() | change or read the zone |
cron.to_string() | the expression, with CRON_TZ= when the zone isn't UTC |
Schedule::next_after(t) | the same for a task schedule |
In debug builds the dev console lists every scheduled task with its next run, and every cron job with its next enqueue.
Caching
zapline::cache (feature cache, included in full) has three tools. Pick by what you want to skip:
| Skip | Use |
|---|---|
| Recomputing a value (a slow query, an API call) | Cache |
Running a handler at all for the same anonymous GET | ResponseCache |
| Sending a body the browser already has | ETag |
Cache: values
#![allow(unused)] fn main() { use std::time::Duration; use zapline::cache::Cache; use zapline::prelude::*; #[model] struct Stats { users: u64 } #[get("/stats")] async fn stats(cache: Cache) -> Result<Json<Stats>> { let stats = cache .get_or_set("stats", Duration::from_secs(60), || async { Ok(Stats { users: 42 }) // the expensive part }) .await?; Ok(Json(stats)) } let app = App::new().cache(Cache::memory()).mount(stats); }
get_or_set is the one to reach for. When 500 requests miss at once, one computes and the rest wait for it (no stampede). Errors aren't cached.
| Call | |
|---|---|
cache.get::<T>(key) | Option<T>: a miss when absent, expired, or of another type |
cache.set(key, &value, ttl) | Any Serialize value (stored as JSON) |
cache.delete(key) / delete_prefix("posts:") / clear() | Invalidate after writes |
Cache::memory().max_entries(n) | Capacity (default 10,000). When full, expired entries go first, then the soonest to expire. |
Cache::redis(redis) | The same API in Redis (feature redis), shared by every instance; see Running several instances. |
Name keys like posts:42 so a write can drop a whole family with delete_prefix.
ResponseCache: whole pages
#![allow(unused)] fn main() { use zapline::cache::ResponseCache; let app = App::new().nest( "/blog", Routes::new().mount((index, post)).wrap(ResponseCache::new(Duration::from_secs(300))), ); }
Repeats are answered from memory with x-cache: HIT. It only keeps responses that are safe to share:
GETorHEADwith noAuthorizationorCookieheader, so logged-in users always get their own page;- a
200with a buffered body, noSet-Cookie, and noCache-Control: private,no-storeorno-cache.
The query string, Accept and HX-Request are part of the key, so htmx fragments and full pages don't mix. When the handler set no Cache-Control, public, max-age=<ttl> is added, so CDNs and browsers help too.
ETag: conditional requests
#![allow(unused)] fn main() { let app = App::new().wrap(zapline::cache::ETag); }
200 responses to GET get a weak ETag (a hash of the body). A browser that sends it back in If-None-Match gets an empty 304 Not Modified. Streamed bodies are left alone. Static files have their own ETags already.
Databases
zapline::db connects your app to Postgres or SQLite through sqlx: a connection pool created at startup, SQL migrations applied automatically, a Db extractor, a readiness check, and database errors that become the right HTTP status.
[dependencies]
zapline = { version = "0.1", features = ["sqlite"] } # or "postgres", or both
sqlx = { version = "0.8", default-features = false, features = ["derive"] } # for #[derive(FromRow)]
use serde::Serialize;
use zapline::db::{sqlx, Database, Db};
use zapline::prelude::*;
#[derive(Serialize, sqlx::FromRow)]
struct Post { id: i64, title: String }
#[get("/posts/{id}")]
async fn get_post(db: Db, Path(id): Path<i64>) -> Result<Json<Post>> {
let post = sqlx::query_as::<_, Post>("SELECT id, title FROM posts WHERE id = ?")
.bind(id)
.fetch_one(&*db) // no row → 404
.await?;
Ok(Json(post))
}
fn main() -> std::io::Result<()> {
App::new()
.database(Database::from_env()) // DATABASE_URL
.mount(get_post)
.run("0.0.0.0:8080")
}
Connecting
| Constructor | Use |
|---|---|
Database::from_env() | URL from DATABASE_URL at startup; startup fails with a hint if it's missing |
Database::url("sqlite://app.db?mode=rwc") | A fixed URL |
Database::sqlite(url) / Database::postgres(url) | Pick the backend explicitly (when both features are on) |
Database::sqlite_memory() | A private in-memory database per app: ideal for tests |
Pool settings: .max_connections(n) (default 4 × CPUs, at least 10), .min_connections(n), .acquire_timeout(d) (default 5 s; a request that can't get a connection in time gets a 503).
With both features enabled, Db and Database default to Postgres; use SqliteDb (or Db<sqlx::Sqlite>) and Database::sqlite(url) for SQLite.
Queries
Db dereferences to sqlx::Pool, so every sqlx API works: .fetch_one(&*db), .execute(&*db), and db.begin().await? for a transaction (commit with tx.commit().await?; dropping it rolls back).
? on a sqlx::Error gives:
| sqlx error | HTTP |
|---|---|
RowNotFound | 404 |
| unique violation | 409, code unique_violation |
| foreign-key violation | 409, code foreign_key_violation |
| not-null / check violation | 422, code constraint_violation |
| pool timeout | 503, code db_pool_timeout |
| anything else | 500 (details logged, not sent) |
Resources made by zapline g resource turn the two 409s into errors that name the field: 422 author_not_found for a reference to a row that doesn't exist, 409 email_taken for a duplicate in an email:string:unique column (see Resource fields). The generated write_error function shows how: on a foreign-key violation it checks which parent is missing, on a unique violation it matches the index name (users_email_key).
List endpoints: paging, sorting, filters, search
Listing turns the query string into a safe, paged query. Callers get pagination, sorting, filters and search; you write one expression:
#![allow(unused)] fn main() { use zapline::db::{sqlx, Db, ListParams, Listing, Paged}; #[get("/api/posts")] async fn list(db: Db, params: ListParams) -> Result<Json<Paged<Post>>> { let page = Listing::new("posts", "id, title, published, author_id") .sortable(["title", "published"]) .search(["title", "body"]) .filter_bool("published") .filter_int("author_id") .fetch(&db, ¶ms) .await?; Ok(Json(page)) } }
GET /api/posts?q=rust&published=true&sort=-id&limit=20&offset=40 returns:
{"items": [...], "total": 57, "limit": 20, "offset": 40}
| Query | Meaning |
|---|---|
limit, offset | Page size (default 50, capped at 200; change with .page_size(default, max)) and start. |
sort=title, sort=-title, sort=-done,title | Ascending, descending, several keys. Only .sortable(..) columns and the key (id) are allowed. Ties are broken by the key, so pages are stable. |
q=words | Case-insensitive substring match over the .search(..) columns. % and _ match themselves. |
published=true, author_id=7, status=paid | Equality filters declared with .filter_bool, .filter_int and .filter_text. |
Nothing from the request reaches the SQL text: values are bound as parameters, and sort columns must be on the allow-list. Bad input is a 400 with code invalid_sort, invalid_filter or invalid_page, and the message lists what's allowed.
For per-user or per-tenant lists, .scope("user_id", user.id) adds a condition the request can't remove. zapline generate resource writes a listing() like this for every resource, with sorting on every column but long text, search on the text fields and a filter per string, number, boolean and reference field; a reference (author:references) also gets a nested list, GET /api/authors/{id}/posts, which is listing().scope("author_id", id). It works on SQLite and Postgres, and ListParams documents limit, offset, sort and q in the OpenAPI spec.
Migrations
Migrations are plain SQL files in migrations/, applied in version order, each exactly once (sqlx tracks them in _sqlx_migrations). Each one is a pair: <version>_<name>.up.sql does the change and <version>_<name>.down.sql undoes it.
zapline db new create_posts # migrations/20261022062406_create_posts.up.sql + .down.sql
zapline db migrate # apply pending migrations, then exit
The app also applies pending migrations at startup, so zapline dev and deploys need no extra step. Turn that off with Database::from_env().migrate(false), or read them from elsewhere with .migrations("db/migrations"). .migrations(zapline::embed!("migrations")) compiles them into the binary, with the same checksums as the files (One binary). zapline db migrate runs your app with ZAPLINE_MIGRATE=up, so it uses exactly the URL and settings the app uses.
In Docker, copy
migrations/next to the binary (the generated Dockerfiles do).
Writing them for you
zapline g migration writes both halves from the name and field:type arguments (CLI):
zapline g migration create_tags name slug # CREATE TABLE / DROP TABLE
zapline g migration add_published_at_to_posts published_at:datetime? # ADD COLUMN / DROP COLUMN
zapline g migration add_views_to_posts views:int=0 # ... with the default existing rows get
zapline g migration remove_legacy_from_posts legacy:bool # DROP COLUMN / ADD COLUMN
zapline g migration backfill_slugs # an empty pair to fill in
A required column added to an existing table gets a default so the rows already there stay valid: the one after =, or '', 0, FALSE; dates, UUIDs, JSON and references need an = value or have to be optional there. :unique and :index work here too (add_slug_to_posts slug:string?:unique) and add the index; the down file, and a later remove_ migration, drop it before the column. When the table came from zapline g resource, its module, pages and test are updated to read and write the new columns, your own edits kept (The resource follows the table). zapline g resource and g auth write their down file (DROP TABLE) too.
Status, rollback, redo, reset
zapline db status # every migration, applied or not, and edited files
zapline db rollback # revert the last migration with its .down.sql
zapline db rollback --steps 3 # ... or the last three, newest first
zapline db redo # revert the last one and apply it again
zapline db reset # revert everything, migrate, run the seed (development)
VERSION NAME APPLIED AT STATE DOWN
20260101000000 create todos 2026-10-23 08:54:03 applied yes
20261022062406 add email to users 2026-10-23 08:54:03 CHANGED since applied yes
20261023090000 create comments - pending yes
2 applied, 1 pending
CHANGED since applied means the file was edited after it ran: the app refuses to start until it matches again (edit a migration that already shipped by adding a new one instead). zapline db status then exits non-zero and names the file to restore, as it does for an applied migration whose file was deleted, so a CI step can catch both. Nothing is reverted unless every migration involved has a down file; a one-way migration from an older project (<version>_<name>.sql) becomes reversible when you add <version>_<name>.down.sql next to it. zapline db new keeps writing one-way files in a project whose migrations are all one-way, unless you pass --reversible.
Every command but new runs your app with ZAPLINE_MIGRATE=status|up|down:<n>|redo|reset (plus ZAPLINE_SEED=1 for reset), so it uses exactly the URL and settings the app uses; the app does the work and exits instead of serving. The same commands are there in code, for tests and one-off tools:
use zapline::db::{Migrations, MigrationState};
let migrations = Migrations::open("migrations").await?;
migrations.up(&pool).await?;
let pending = migrations
.status(&pool)
.await?
.iter()
.filter(|m| m.state == MigrationState::Pending)
.count();
migrations.down(&pool, 1).await?;
Guard rails
Rolling back drops tables, so production is protected twice:
zapline db rollback,redoandresetrefuse--release(a release build) andAPP_ENV/ZAPLINE_ENV=productionunless you add--force.- The app itself, built in release mode, refuses
ZAPLINE_MIGRATE=down:<n>,redoandresetunlessZAPLINE_ALLOW_DESTRUCTIVE=1is set (--forcesets it).
The MCP tools db_rollback, db_redo and db_reset need confirm=true, so an assistant has to ask first.
Dates, times and UUIDs
Turn on zapline's chrono and uuid features and use zapline::chrono and zapline::uuid in models: sqlx, serde and the OpenAPI schema (format: date, date-time, uuid) all understand them, with no extra dependencies. zapline g resource turns the features on when its fields need them.
use zapline::chrono::{DateTime, NaiveDate, Utc};
use zapline::uuid::Uuid;
#[model]
#[derive(sqlx::FromRow)]
pub struct Event {
pub id: i64,
pub day: NaiveDate, // DATE on Postgres, TEXT ("2026-01-31") on SQLite
pub starts_at: DateTime<Utc>, // TIMESTAMPTZ / TEXT
pub token: Uuid, // UUID / BLOB
pub created_at: DateTime<Utc>, // DEFAULT CURRENT_TIMESTAMP works on both
}
#[model]
pub struct NewEvent {
pub day: NaiveDate,
// Accepts RFC 3339 and what <input type="datetime-local"> sends ("2026-01-31T09:30", as UTC).
#[serde(deserialize_with = "zapline::extract::datetime")]
pub starts_at: DateTime<Utc>,
}
zapline::extract::optional_datetime is the same for an Option<DateTime<Utc>> field (empty means None).
JSON columns
With the sqlite or postgres feature, sqlx reads and binds serde_json::Value (re-exported as zapline::db::sqlx::types::JsonValue) for a JSONB column on Postgres and a TEXT one on SQLite, and sqlx::types::Json<T> for your own type. zapline g resource settings prefs:json generates such a column.
use zapline::db::sqlx::types::JsonValue;
#[model]
#[derive(sqlx::FromRow)]
pub struct Settings {
pub id: i64,
pub prefs: JsonValue, // JSONB on Postgres, TEXT on SQLite
}
#[model]
pub struct NewSettings {
// A JSON body sends {"prefs": {"theme": "dark"}}; a form's <textarea> sends the text.
#[serde(deserialize_with = "zapline::extract::json_text")]
pub prefs: JsonValue,
}
zapline::extract::json_text parses a string that holds a JSON object or array, reads an empty string as null (so Option<JsonValue> is None; add #[serde(default)]), and leaves other values alone. Malformed JSON is a 422 naming the field. In templates, {{ item.prefs|tojson }} writes the value back as JSON.
Postgres can query into JSONB (WHERE prefs->>'theme' = $1); SQLite has json_extract(prefs, '$.theme'). Neither is portable, so write those queries for the dialect you use.
Seed data
Demo or development data goes in App::seed, after .database(..):
use zapline::db::{Database, Factory};
App::new()
.database(Database::from_env())
.seed(|db| async move {
Factory::new("todos").set("title", "Try zapline").create(&db).await?;
Factory::new("todos").set("done", true).create_many(&db, 5).await?;
Ok(())
})
zapline db seed # migrate, run the seed once, exit
Normal runs and tests never run it. zapline db seed starts the app with ZAPLINE_SEED=1, like db migrate, so it uses the app's own DATABASE_URL. Running it twice inserts the rows twice. The fullstack template comes with a seed.
Health
use zapline::db::DbCheck;
App::new()
.database(Database::from_env())
.plugin(Health::new().check("database", DbCheck::new())); // /health/ready pings the database
Testing
Each Database::sqlite_memory() is a fresh, empty database with your migrations applied, so tests run in parallel without stepping on each other:
fn app(db: Database) -> App {
App::new().database(db).mount((list_posts, create_post))
}
fn main() -> std::io::Result<()> {
app(Database::from_env()).run("0.0.0.0:8080")
}
#[tokio::test]
async fn creates_a_post() {
let client = app(Database::sqlite_memory()).test();
let res = client.post("/posts").json(&serde_json::json!({"title": "Hi"})).send().await;
assert_eq!(res.status(), 201);
}
Test data with Factory
Factory inserts a row and fills in every column you don't set that the database requires (NOT NULL, no default), read from the schema: unique numbers, text like title 7, user7@example.com for email columns, false, the current time, {} for JSON. A required foreign key gets a parent row, made the same way. Set only what the test is about:
use zapline::db::{sqlx, DefaultDb, Factory};
#[tokio::test]
async fn lists_only_published_posts() {
let client = app(Database::sqlite_memory()).test();
let pool: sqlx::Pool<DefaultDb> = client.resources().await.require().unwrap();
Factory::new("posts").set("published", true).create_many(&pool, 2).await.unwrap();
Factory::new("posts").set("published", false).create(&pool).await.unwrap(); // its author is created too
let posts: Vec<serde_json::Value> = client.get("/posts").send().await.json();
assert_eq!(posts.len(), 2);
}
create returns a Record: get("title") as text, int("id"), json() with typed numbers and booleans (compare it with your API's response), or parse::<Post>() into your own type. .null("column") stores NULL. It works on SQLite and Postgres, in tests and in seeds.
If your SQL is portable between SQLite and Postgres you can test against SQLite and run Postgres in production; otherwise point a test at a real Postgres with Database::postgres(url).
Going further
Ecosystem has complete, tested apps for transactions, savepoints and idempotency keys with sqlx, and for SeaORM, Diesel and Leptos.
Ecosystem: sqlx, SeaORM, Diesel, Leptos
zapline has no ORM of its own and no opinion on which one you use. Whatever you pick plugs into the same three hooks:
- A startup factory opens the pool and runs migrations
(
App::databasefor sqlx,App::provide_withfor anything else). If it fails,runreturns the error and the port is never bound. - A service built from the pool by a second factory, received in
handlers as
Dep<Service>(see Dependency injection). - A readiness check that pings the database through the same pool (see Health checks).
Four complete, tested apps live in
examples/ecosystem.
Each runs on SQLite with nothing to install:
| Example | Library | Shows |
|---|---|---|
sqlx-transactions | sqlx through zapline::db | Transactions, savepoints, idempotency keys, concurrent writers, a health check that audits the books |
seaorm | SeaORM 1.1 | Entities and relations, sea-orm-migration, eager loading, pagination, partial updates, a multi-step transaction |
diesel | Diesel 2.3 | A blocking ORM on the async server, embedded migrations, stock reservation that never oversells |
leptos-ssr | Leptos 0.8 | Leptos components rendered by zapline handlers, forms that work without JavaScript |
cd examples/ecosystem
cargo test # all four (their own workspace and Cargo.lock)
cd seaorm && cargo run # http://localhost:8080 , API docs at /docs
Transactions with sqlx
Db (and the Pool in resources) is a plain sqlx pool, so a transaction is
begin, your queries on &mut *tx, then commit. Returning early with ?
drops the transaction, and sqlx rolls it back:
pub async fn transfer(&self, t: &NewTransfer, key: Option<&str>) -> Result<Outcome> {
// SQLite: take the write lock up front, so concurrent writers wait on the
// busy timeout instead of failing with "database is locked".
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
if let Some(key) = key {
if let Some(previous) = find_by_key(&mut tx, key).await? {
return replay(previous, t); // a retry: nothing moves twice
}
}
let created = move_money(&mut tx, t, key).await?; // debit, credit, record
tx.commit().await?;
Ok(Outcome::Created(created))
}
Three rules from the ledger example carry over to any app:
-
Check and change in one statement.
UPDATE accounts SET balance_cents = balance_cents - $1 WHERE id = $2 AND balance_cents >= $1can't be raced; aSELECTfollowed by anUPDATEcan. Look atrows_affected()to tell why nothing changed. -
Savepoints for partial success.
tx.begin()inside a transaction issuesSAVEPOINT;commitreleases it androllbackundoes only that part:let mut savepoint = tx.begin().await?; match move_money(&mut savepoint, transfer, None).await { Ok(created) => { savepoint.commit().await?; items.push(BatchItem::ok(i, created)); } Err(e) => { savepoint.rollback().await?; items.push(BatchItem::failed(i, &e)); } } -
Idempotency keys are a
UNIQUEcolumn read and written in the same transaction: a retriedPOSTwith the sameIdempotency-Keyheader gets the original result (200), and the same key with a different body is a422.
A health check can verify more than connectivity. The ledger's checks that money is conserved:
App::new()
.database(db)
.provide_with(|res: Resources| async move {
let pool: SqlitePool = res.require()?; // registered by .database(..)
Ok(Ledger::new(pool))
})
.plugin(
Health::new()
.check("database", DbCheck::new())
.check("books", |res: Resources| async move {
res.dep::<Ledger>()?.check_books().await
}),
)
On Postgres, use begin() instead of BEGIN IMMEDIATE: the conditional
UPDATE locks the row.
SeaORM
Leave zapline's sqlite/postgres features off; SeaORM has its own pool.
Open it (and migrate) in a factory, then build services from it:
App::new()
.provide_with(|_res: Resources| async move {
let db = sea_orm::Database::connect(options).await.map_err(db_error)?;
Migrator::up(&db, None).await.map_err(db_error)?;
Ok(db)
})
.provide_with(|res: Resources| async move {
Ok(Blog::new(res.dep::<DatabaseConnection>()?))
})
.plugin(Health::new().check("database", DatabasePing))
DbErr and zapline::Error are both foreign to your crate, so convert with
a function instead of ? alone. Map them to the codes zapline uses for sqlx,
so clients see one vocabulary:
pub fn db_error(e: DbErr) -> Error {
match e.sql_err() {
Some(SqlErr::UniqueConstraintViolation(_)) => {
return Error::conflict("already exists").with_code("unique_violation");
}
Some(SqlErr::ForeignKeyConstraintViolation(_)) => {
return Error::conflict("referenced row does not exist or is still in use")
.with_code("foreign_key_violation");
}
_ => {}
}
match e {
DbErr::RecordNotFound(_) => Error::not_found(),
DbErr::ConnectionAcquire(_) => Error::service_unavailable("database busy").with_code("db_pool_timeout"),
other => Error::internal(other.to_string()), // logged, not sent
}
}
Transactions use SeaORM's closure form; returning Err from it rolls back
every step:
self.db.transaction::<_, (), DbErr>(|txn| Box::pin(async move {
post::Entity::update_many()
.col_expr(post::Column::AuthorId, Expr::value(into_id))
.filter(post::Column::AuthorId.eq(from_id))
.exec(txn).await?;
author::Entity::delete_by_id(from_id).exec(txn).await?;
if let Some(name) = merge.rename_to {
let mut active: author::ActiveModel = into.into();
active.name = Set(name);
active.update(txn).await?; // a taken name undoes both steps above
}
Ok(())
})).await
Return API types from handlers (#[model] structs with OpenAPI schemas),
not entities; a From<post::Model> impl keeps the mapping in one place.
Diesel
Diesel is synchronous: run it on tokio's blocking pool, never directly in a handler. The example's whole adapter is one method:
pub async fn run<T, F>(&self, f: F) -> Result<T>
where
T: Send + 'static,
F: FnOnce(&mut SqliteConnection) -> Result<T, DbError> + Send + 'static,
{
let pool = self.0.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().map_err(|_| DbError::PoolTimeout)?;
f(&mut conn)
})
.await
.map_err(|_| Error::internal("database task panicked"))?;
Ok(result?)
}
DbError is your own type, so impl From<diesel::result::Error> for DbError
and impl From<DbError> for zapline::Error are allowed, and ? works on
both sides of the closure. Migrations are compiled in with
embed_migrations!("migrations") and applied in the factory that builds the
pool. On SQLite, conn.immediate_transaction(|conn| ..) takes the write
lock up front; with a conditional UPDATE .. RETURNING, thirty concurrent
orders for the last ten units give exactly ten 201s and twenty
409 out_of_stocks.
diesel-async also fits: register its pool
with provide_with and await queries in the service directly.
Leptos (server-side rendering)
Leptos components render to HTML strings with its ssr feature, so a
zapline handler can load data as usual and return the rendered page:
use leptos::prelude::*;
pub fn render<F: FnOnce() -> V, V: IntoView>(view: F) -> Html<String> {
let owner = Owner::new(); // a reactive scope per render
let body = owner.with(|| view().to_html());
Html(format!("<!DOCTYPE html>{body}"))
}
#[get("/")]
async fn home(db: Db) -> Result<Html<String>> {
let notes = all_notes(&db).await?;
Ok(render(move || view! {
<Page title="Notes">
<NotesBoard notes=notes error=None draft=String::new()/>
</Page>
}))
}
Forms post to ordinary zapline routes, which answer with Redirect::to("/")
(Post/Redirect/Get), or with status 422 and the page re-rendered with the
error and the user's input. Leptos escapes text, so user content can't
inject markup. For browser interactivity, build a second crate with Leptos's
hydrate feature to WASM and serve its pkg/ directory with
ServeDir; the server side stays the same.
If you prefer templates to components, zapline's own views (MiniJinja + htmx) need no extra crates.
Views & htmx
zapline::view (feature views) renders HTML with MiniJinja templates (Jinja2 syntax: {{ value }}, {% for %}, {% extends %}, {% block %}). It is built for htmx: the same template renders a whole page for a normal visit and just one block for an htmx request, so there is no separate "partials" folder to keep in sync.
use zapline::prelude::*;
use zapline::view::{context, Hx, HxRequest, Views};
#[get("/todos")]
async fn todos(views: Views, hx: HxRequest, db: Db) -> Result<Html<String>> {
let todos = load_todos(&db).await?;
views.page_or_block(&hx, "todos.html", "list", context! { todos })
}
#[post("/todos")]
async fn add(db: Db, Form(new): Form<NewTodo>) -> Result<Response> {
insert_todo(&db, new).await?;
Ok(Hx::trigger("todos-changed").with(StatusCode::CREATED))
}
fn main() -> std::io::Result<()> {
App::new().views("templates").mount((todos, add)).run("0.0.0.0:8080")
}
templates/todos.html:
{% extends "base.html" %}
{% block content %}
<form hx-post="/todos" hx-swap="none">
<input name="title" required> <button>Add</button>
</form>
<ul id="list" hx-get="/todos" hx-trigger="todos-changed from:body">
{% block list %}
{% for t in todos %}<li>{{ t.title }}</li>{% endfor %}
{% endblock %}
</ul>
{% endblock %}
API
App::views("templates") | Load templates from a directory (relative to the project root) |
App::views_with("templates", |env| { .. }) | Same, plus filters, functions and globals on the MiniJinja Environment |
views.render(name, ctx) | The whole template → Html<String> |
views.render_block(name, block, ctx) | Only {% block block %} (with the same context) |
views.page_or_block(&hx, name, block, ctx) | render_block for htmx requests, render otherwise (boosted links and history restores get the full page) |
ValidForm(form): ValidForm<T> | A validated form post as Result<T, Invalid>: never rejects bad input (Forms) |
views.render_invalid(name, ctx, invalid) | 422 with errors and old in the context; the form block for htmx |
flash: Flash | flash.success(msg) / .error / .info / .warning, flash.redirect(to); {% for m in flash %} in templates (Flash messages) |
HxRequest | Extractor: htmx, boosted, history_restore, target, trigger from the HX-* headers |
Hx::redirect / refresh / trigger / retarget / push_url | htmx response headers; .with(response) adds them to any response |
context! { a, b => expr } | Build a template context; any Serialize value works too |
Values are HTML-escaped automatically in .html templates. Use {{ value | safe }} only for HTML you produced yourself.
Forms: errors next to each field
A rejected form should come back with a message next to each field and what the user typed still in the inputs. ValidForm<T> parses and validates a form post without rejecting it, and views.render_invalid shows the form again:
use serde::{Deserialize, Serialize};
use validator::Validate;
use zapline::prelude::*;
use zapline::session::Flash;
use zapline::view::{context, FormErrors, Invalid, ValidForm, Views};
#[derive(Deserialize, Serialize, Validate)]
struct NewPost {
#[validate(length(min = 1, max = 200))]
title: String,
views: i64,
}
#[get("/posts/new")]
async fn new_post(views: Views) -> Result<Html<String>> {
views.render("posts/new.html", context! {})
}
#[post("/posts")]
async fn create(views: Views, flash: Flash, db: Db, ValidForm(form): ValidForm<NewPost>) -> Result<Response> {
let post = match form {
Ok(post) => post,
// 422 with `errors` and `old` in the template context.
Err(invalid) => return views.render_invalid("posts/new.html", context! {}, invalid),
};
if title_taken(&db, &post.title).await? {
// Checks only the handler can make render the same way.
let errors = FormErrors::one("title", "is already used");
return views.render_invalid("posts/new.html", context! {}, Invalid::new(errors, &post));
}
insert(&db, &post).await?;
flash.success("Post created");
Ok(flash.redirect("/posts")) // 303, or HX-Redirect for htmx
}
templates/posts/new.html:
{% extends "base.html" %}
{% block content %}
<form id="post-form" hx-post="/posts">
{% block form %}
{% if errors._form %}<p role="alert">{{ errors._form }}</p>{% endif %}
<label>Title <input name="title" value="{{ old.title }}" aria-invalid="{{ 'true' if errors.title else 'false' }}"></label>
{% if errors.title %}<small class="error">{{ errors.title }}</small>{% endif %}
<label>Views <input name="views" type="number" value="{{ old.views }}"></label>
{% if errors.views %}<small class="error">{{ errors.views }}</small>{% endif %}
<button>Save</button>
{% endblock %}
</form>
{% endblock %}
ValidForm<T>gives the handlerOk(T), orErr(Invalid { errors, old }). Fields that don't parse get a message (views=abc: "must be a number"; empty or missing: "is required"); once every field parses, thevalidatorrules run, with the same messages as the JSON 422 (Validation). Only a request that isn't a form at all (wrongContent-Type, too large) is rejected, likeForm<T>.errorsis aFormErrors:{{ errors.title }}is the first message fortitle, undefined (falsy) when the field is fine;{{ errors._form }}holds errors about the whole form. Build one by hand withFormErrors::one(field, msg)/.add(field, msg), or from any error withFormErrors::from_error(&err).oldholds the submitted values by field name, HTML-escaped like any value. Fields whose names look secret (password,token,secret,*_hash) are never echoed back.- Both are empty globals in every template, so the same template renders the blank form and the rejected one.
render_invalidanswers422 Unprocessable Entity. For a normal form post it renders the whole page. For htmx it renders only{% block form %}(the inside of the<form>; the whole template if it has no such block). When the form has anidbut posts somewhere else (hx-target="#list", a create form above a list), it addsHX-Retarget: #post-formandHX-Reswap: innerHTML, so the messages land in the form instead of the list.
htmx does not swap error responses by default. Allow 422 once, in the layout:
<script>
// Invalid forms come back as 422 with the form re-rendered: swap it in.
document.addEventListener("htmx:beforeSwap", (e) => {
if (e.detail.xhr.status === 422) { e.detail.shouldSwap = true; e.detail.isError = false; }
});
</script>
Apps created with zapline new --template fullstack have this in templates/base.html, and zapline g resource and zapline g auth generate forms that work this way.
Flash messages
With sessions (cookies feature and a SessionLayer), Flash queues one-time messages for the next page, and every template sees the waiting ones as flash:
use zapline::session::Flash;
#[post("/posts/{id}/delete")]
async fn delete(flash: Flash, db: Db, Path(id): Path<i64>) -> Result<Response> {
delete_post(&db, id).await?;
flash.success("Post deleted"); // also .error(..), .info(..), .warning(..)
Ok(flash.redirect("/posts"))
}
{# base.html #}
<div id="flash">
{% for m in flash %}<p role="status" class="flash flash-{{ m.level }}">{{ m.message }}</p>{% endfor %}
</div>
- A message stays in the session until something reads it: the first template that uses
flash(later reads in the same request see the same list), orflash.take()in a handler (for JSON clients). A message queued while rendering a page shows on that page. flash.redirect(to)answers303 See Other, orHX-Redirectfor htmx (which then loads the whole page, where the flash shows).- An htmx request answered in place (a fragment, no redirect) gets its new messages right away as
HX-Trigger: {"flash": {"messages": [{"level": "success", "message": "Saved"}]}}, merged with anyHX-Triggerthe handler set. Show them as toasts with a few lines of script listening for theflashevent (e.detail.messages); the fullstack layout does. - No cost when unused:
flashis a lazy template global. A page reads the session store only when a small marker cookie (zapline.sid.flash) says a message is waiting.
Hot reload
In debug builds templates are read from disk on every render: edit a template and refresh, no rebuild. Under zapline dev you don't even refresh: open pages reload by themselves when a template changes (also with --release, since ZAPLINE_DEV=1 keeps reloading on). Release builds load each template once. To ship the templates inside the binary, pass App::views(zapline::embed!("templates")): release builds then read them from memory, and debug builds still read the directory, so hot reload keeps working (One binary). Otherwise copy templates/ next to the binary when you deploy (the generated Dockerfiles do).
Errors
A template error (missing template, syntax error, unknown filter) is a 500. Under zapline dev (which sets ZAPLINE_DEV=1) the response carries the full message with file and line; in production it is logged and the client gets a generic message.
Durable jobs
Work that must not be lost (emails, webhooks, image processing, billing) goes in a job: a struct stored in your database until a worker has run it. Jobs survive restarts and deploys, are retried with backoff when they fail, and are shared by every instance of the app.
use zapline::db::Database; use zapline::jobs::{Job, Jobs}; use zapline::prelude::*; #[model] struct SendWelcome { user_id: i64, } impl Job for SendWelcome { const NAME: &'static str = "send_welcome"; async fn run(self, res: Resources) -> Result<()> { // load the user with res.require::<..>()?, send the email... Ok(()) } } #[post("/signup")] async fn signup(jobs: Jobs) -> Result<StatusCode> { // create the user... jobs.enqueue(SendWelcome { user_id: 1 }).await?; Ok(StatusCode::CREATED) } fn main() -> std::io::Result<()> { App::new() .database(Database::from_env()) .jobs(|queue| queue.register::<SendWelcome>()) .mount(signup) .run("0.0.0.0:8080") }
Requirements: the postgres or sqlite feature, and json (on by default). zapline g job <name> writes the struct, its registration and a test for you.
Writing a job
- The struct is the payload, stored as JSON: keep it small (ids, not whole records).
#[model]derives what it needs. NAMEidentifies the job type in the database. Keep it stable when you rename or move the struct, or queued jobs will find no handler.rungets the app'sResources, so it can use everything a handler can:res.require::<Pool<Postgres>>()?,res.require::<Mailer>()?, your own services.- Return
Err(or panic) to have the job retried.MAX_ATTEMPTS(default 5) caps the runs. - Jobs run at least once: if a worker dies mid-run, the job runs again after its lease expires. Make jobs safe to repeat (check before you charge, upsert instead of insert).
Enqueueing
| Call | Runs |
|---|---|
jobs.enqueue(job).await? | as soon as a worker is free |
jobs.enqueue_in(job, Duration::from_secs(3600)).await? | after the delay |
jobs.enqueue_at(job, SystemTime).await? | at that time (right away if it has passed) |
Jobs is an extractor, and also available from Resources (res.require::<Jobs>()?), so a job can enqueue follow-up jobs. Enqueueing a type that isn't registered in .jobs(..) is an error that names the missing .register::<T>().
All three return Ok(true) when the job was stored, and Ok(false) when it was a duplicate (below).
Unique jobs, priorities and queues
Three optional items on Job shape how a type is queued:
#![allow(unused)] fn main() { impl Job for ReindexProduct { const NAME: &'static str = "reindex_product"; const QUEUE: &'static str = "search"; // default "default" const PRIORITY: i32 = -1; // default 0; higher runs first // At most one queued or running job per product. fn unique_key(&self) -> Option<String> { Some(self.product_id.to_string()) } async fn run(self, res: Resources) -> Result<()> { /* ... */ Ok(()) } } }
unique_key: while a job of this type with the same key is queued or running, enqueueing another stores nothing and returnsOk(false), so ten edits in a row reindex once. A partial unique index on(name, unique_key)enforces it, across instances too. Once the job has finished (or failed for good), the key is free.enqueuereports duplicates itself instead of a separateenqueue_unique, because uniqueness is a property of the job type: every caller gets it, whether or not it checks the result.PRIORITY: due jobs are claimed highest priority first, then oldest first. A password-reset email (10) overtakes a backlog of newsletters (-5).QUEUE: by default an instance's workers take jobs from every queue its registered types use..queues(..)gives each queue its own workers instead, so a flood in one can't hold up another:
#![allow(unused)] fn main() { App::new() .database(Database::from_env()) .jobs(|queue| { queue .register::<SendWelcome>() .register::<ReindexProduct>() .queues([("default", 4), ("search", 1)]) // or .queues(["default", "search"]) with .workers(n) each }) }
An instance only works the queues it lists, so a dedicated search worker is the same app started with .queues([("search", 8)]).
Workers, retries and failures
App::jobs starts the workers at startup and stops them at shutdown, after their current job (within App::tasks_grace_period).
| Setting | Default | |
|---|---|---|
.workers(n) | 4 | Jobs run at once in this instance. 0 runs none: useful in tests, or for web instances when a separate instance runs the jobs. |
.queues([..]) | every queue in use, shared | Dedicated workers per queue: ("mail", 2), or a bare name for .workers(n) workers. |
.poll_interval(d) | 1 s | How often idle workers look for due jobs. Jobs enqueued by the same instance wake a worker at once. |
.timeout(d) | 5 min | A run that takes longer fails (and is retried). |
A failed run is retried after 5 s, 20 s, 80 s, 5 min, 21 min (capped at 1 h) until MAX_ATTEMPTS. Then the job stays in the table with status failed and its last error. Finished jobs are deleted.
#![allow(unused)] fn main() { let stats = jobs.stats().await?; // JobStats { queued, running, failed } for job in jobs.failed(20).await? { // newest first: id, name, queue, attempts, last_error, payload if job.name == "obsolete_job" { jobs.discard(job.id).await?; // delete it (also cancels a queued job) } } let n = jobs.retry_failed().await?; // queue every failed job again }
retry_failed leaves a failed job alone when a newer job holds its unique key. In debug builds, the dev console shows the queue and the latest failures, with a button that retries them all and a Discard button on each failed job, plus per-job counts when metrics are on and every cron job with its next enqueue.
The table is zapline_jobs (created at startup), so plain SQL works for anything else: SELECT name, last_error FROM zapline_jobs WHERE status = 'failed'. A table created by an older zapline gets the queue, priority and unique_key columns added at startup; existing rows land in the default queue at priority 0.
On Postgres, workers claim jobs with FOR UPDATE SKIP LOCKED, so any number of instances share one queue without running a job twice. SQLite serializes writes, which gives the same guarantee for a single server.
Cron jobs
queue.cron(expr, job) enqueues job every time a cron expression matches, and registers the job type if you haven't:
#![allow(unused)] fn main() { use zapline::db::Database; use zapline::jobs::Job; use zapline::prelude::*; #[model] struct SendDigest { period: String, } impl Job for SendDigest { const NAME: &'static str = "send_digest"; async fn run(self, _res: Resources) -> Result<()> { // build and send the digest for self.period Ok(()) } } fn app() -> App { App::new() .database(Database::from_env()) .jobs(|queue| { queue .cron("0 8 * * MON-FRI", SendDigest { period: "daily".into() }) .cron("CRON_TZ=+01:00 0 8 * * MON", SendDigest { period: "weekly".into() }) }) } }
Unlike App::schedule, which runs in every instance, a cron job is enqueued once per tick for the whole cluster, however many instances share the database:
- Each tick is a row in
zapline_cron(key: the job name and the expression; value: the next tick). At the tick, instances race to move it forward in a transaction; only the one that moves it inserts the job, in the same transaction. - The job is then an ordinary job: any worker runs it, with retries and backoff.
- Only instances with workers enqueue (
.workers(0)web instances don't). - If every instance was down at a tick, the job is enqueued once when the first instance comes back, then the schedule resumes. Missed ticks don't pile up.
- The job's fields are serialized once, at startup: every tick enqueues the same payload. Schedule the same job type with different payloads on different expressions, as above. Scheduling one type twice on the same expression panics at startup, and so does an invalid expression.
- Changing a job's expression starts a new row; the old one is ignored.
Metrics
Install the Metrics plugin (.plugin(Metrics::new()), in any order with .jobs(..)) and the queue exports these series on GET /metrics, labelled by job name. Without the plugin nothing is recorded:
| Series | Type | Counts |
|---|---|---|
jobs_enqueued_total{job} | counter | jobs added, by enqueue, enqueue_in or a cron tick |
jobs_started_total{job} | counter | runs started (one per attempt) |
jobs_succeeded_total{job} | counter | runs that succeeded |
jobs_failed_total{job} | counter | runs that failed: returned Err, panicked or timed out |
jobs_retried_total{job} | counter | failed runs that were scheduled again |
jobs_dead_total{job} | counter | jobs that ran out of attempts (status failed) |
jobs_duration_seconds{job} | histogram (5 ms to 15 min) | how long runs took |
Useful alerts: increase(jobs_dead_total[1h]) > 0, and a failure ratio of rate(jobs_failed_total[5m]) / rate(jobs_started_total[5m]). Counters are per instance, so sum by (job) across instances. The queue depth is in the table: jobs.stats() or SELECT status, COUNT(*) FROM zapline_jobs GROUP BY status.
Testing jobs
With .workers(0) nothing runs in the background, and run_pending runs the due jobs on the spot, so tests are deterministic:
#![allow(unused)] fn main() { let client = App::new() .database(Database::sqlite_memory()) .jobs(|queue| queue.register::<SendWelcome>().workers(0)) .test(); let jobs: Jobs = client.resources().await.require()?; client.post("/signup").send().await.assert_status(201); assert_eq!(jobs.run_pending().await?, 1); }
Jobs or background tasks?
BackgroundTasks | Jobs | |
|---|---|---|
| Stored | in memory | in the database |
| Survives a crash or deploy | no | yes |
| Retries | no | with backoff |
| Shared across instances | no | yes |
| Needs | nothing | a database |
Use background tasks for best-effort work (an audit log line, a cache warm-up) and jobs for everything a user would notice missing.
zapline::mail sends email over SMTP in production, keeps it in a dev inbox you can open in the browser while developing, and captures it in tests.
use zapline::mail::{Email, Mailer}; use zapline::prelude::*; #[post("/invite")] async fn invite(mail: Mailer) -> Result<&'static str> { mail.send( Email::to("ada@example.com") .subject("You're invited") .text("Join us: https://example.com/join") .html("<p>Join us: <a href=\"https://example.com/join\">example.com</a></p>"), ) .await?; Ok("sent") } fn main() -> zapline::Result<()> { App::new() .mailer(Mailer::from_env()?) .mount(invite) .run("0.0.0.0:8080")?; Ok(()) }
Requirements: the mail feature (included in full).
Configuration
Mailer::from_env() reads:
| Variable | Example | |
|---|---|---|
SMTP_URL | smtps://user:pass@smtp.example.com | Implicit TLS (port 465). Use smtp://user:pass@host:587?tls=required for STARTTLS and smtp://localhost:1025 for a local catcher like Mailpit. |
MAIL_FROM | Acme <no-reply@acme.dev> | The sender when an Email doesn't set .from(..). Defaults to no-reply@localhost. |
Without SMTP_URL, a debug build uses the outbox described below, and a release build refuses to start, so production never drops mail silently.
Mailer::smtp(url) and Mailer::outbox() build the two kinds directly, and .default_from(addr) sets the sender in code.
Recipients and headers
#![allow(unused)] fn main() { Email::to("ada@example.com") .and_to("grace@example.com") // more `To` recipients .cc("team@example.com") // visible copies .bcc("audit@example.com") // blind copies .reply_to("support@example.com") .header("List-Unsubscribe", "<https://example.com/unsubscribe?u=42>") .subject("Release notes") .text("..."); }
Blind copies go to the SMTP envelope only: the sent message has no Bcc header, so no recipient can see them. header(name, value) adds any other header; sending fails with a clear error for a value with a line break (header injection), a name that isn't a valid header name, or a header that has its own method (To, Subject, Content-Type, ...).
Attachments and inline images
#![allow(unused)] fn main() { let email = Email::to(&user.email) .subject("Your invoice") .text("Your invoice is attached.") .html(r#"<img src="cid:logo" alt="Acme"><p>Your invoice is attached.</p>"#) .attach("invoice-42.pdf", "application/pdf", pdf_bytes) .inline("logo", "image/png", logo_png); }
attach adds a file the recipient can download; inline embeds an image the HTML shows with src="cid:<id>". Together they may be at most MAX_ATTACHMENT_BYTES (10 MiB, before base64 makes them a third larger): a larger email fails to send, and fails to enqueue, with an error that says so. For big files, send a storage link instead.
email.to_mime(default_from) returns the message exactly as it would be sent, for saving as an .eml file or checking in tests.
Templated email: zapline g mailer
With the views feature, Email::template renders the subject and both bodies from templates in the app's templates/ directory:
#![allow(unused)] fn main() { use zapline::view::{context, Views}; #[post("/signup")] async fn signup(views: Views, mail: Mailer, Json(form): Json<Signup>) -> Result<StatusCode> { let url = format!("https://app.example.com/confirm?t={}", token(&form.email)); let email = Email::to(&form.email) .template(&views, "mail/welcome", context! { name => form.name, url })?; mail.send(email).await?; Ok(StatusCode::CREATED) } }
templates/mail/welcome.html is the HTML body; its {% block subject %} is the subject, so it fits in <title> (and in a shared layout that other emails extend):
<title>{% block subject %}Welcome, {{ name }}{% endblock %}</title>
<p>Hi {{ name }}, please <a href="{{ url }}">confirm your address</a>.</p>
templates/mail/welcome.txt, when it exists, is the plain-text body (not HTML-escaped). Without it the text part is made from the HTML: paragraphs and line breaks kept, links as label (url), lists as - item. Either template may be missing, not both.
zapline g mailer welcome writes all of it: src/mail/welcome.rs with pub fn welcome(views: &Views, to: &str) -> Result<Email>, declared in src/mail/mod.rs (and pub mod mail; in src/lib.rs), the two templates, and tests/welcome_email.rs, which renders the email, sends it to an outbox mailer and checks the subject, the recipient and both bodies. It turns on the mail and views features and tells you what is left to register on the app.
The dev inbox
An outbox mailer sends nothing. It logs each message and serves the last 50 at GET /_dev/mail: subject, recipients (with blind copies listed apart, since the message itself doesn't carry them), the HTML body in a sandboxed frame with its inline images, an HTML / text toggle with clickable links in the text, download links for attachments, and the whole message as an .eml file. Sign-up confirmations and password-reset links work end to end on your machine with no mail server. The page only exists when the outbox is in use, and the dev console links it.
Sending in the background
Sending inline makes the request wait for the SMTP server and fail if it is down. With a database, Email is also a durable job: register it once and enqueue messages instead of sending them.
#![allow(unused)] fn main() { App::new() .database(Database::from_env()) .mailer(Mailer::from_env()?) .jobs(|queue| queue.register::<Email>()); // in a handler jobs.enqueue(Email::to(&user.email).subject("Welcome").text(body)).await?; }
A failed send is retried with backoff (5 s, 20 s, 80 s, ...). Attachments travel with the job (stored base64 in its payload).
Testing
#![allow(unused)] fn main() { let mailer = Mailer::outbox(); let client = App::new().mailer(mailer.clone()).mount(invite).test(); client.post("/invite").send().await.assert_status(200); let sent = mailer.sent(); assert_eq!(sent[0].to, ["ada@example.com"]); assert!(sent[0].text.as_deref().unwrap().contains("/join")); }
Email is a plain struct with public fields (to, cc, bcc, subject, text, html, headers, attachments), so assertions can look at anything. Generated emails come with such a test (zapline g mailer).
Admin panel
App::admin serves an admin panel for every table in your database: browse, search, sort and page through rows, and create, edit and delete them through forms. There is no code per table. It reads the schema on each request, so a table added by a new migration shows up on the next reload.
#![allow(unused)] fn main() { App::new() .database(Database::from_env()) .admin(|admin| admin) // http://localhost:8080/admin }
It works on SQLite and Postgres and needs no extra Cargo feature. The fullstack template turns it on.
Who can open it
Without protect, the panel behaves like the dev console: it exists only in debug builds and answers only requests from the same machine. In a release build that call does nothing, so it can stay in the code.
To use it in production, protect it with a middleware that decides who is an admin. The panel is then served in every build, to whoever the middleware lets through:
#![allow(unused)] fn main() { use zapline::middleware::Next; async fn admins_only(mut req: Request, next: Next) -> Response { let user = match CurrentUser::from_request_parts(&mut req).await { Ok(CurrentUser(user)) => user, Err(_) => return Redirect::to("/login").into_response(), }; let admins = std::env::var("ADMIN_EMAILS").unwrap_or_default(); // Anyone can sign up with any address: trust it only once its owner // has proved it (a used reset link, a GitHub/Google login). if !admins.split(',').any(|e| e.trim().eq_ignore_ascii_case(&user.email)) || !user.email_verified { return Error::forbidden().into_response(); } next.run(req).await } app.admin(|admin| admin.protect(admins_only)) }
zapline g auth generates this: the accounts listed in ADMIN_EMAILS get in once their owner has proved the address (by using a password reset link, or by logging in with GitHub/Google), others get 403, and visitors are sent to /login. Calling admin again replaces the earlier setup, which is how the generated auth::mount protects the panel that the template turned on.
Form posts must come from the same site: a request whose Origin doesn't match its Host gets 403 cross_origin, on top of the SameSite=Lax session cookie.
What it shows
| Shown | Not shown |
|---|---|
Tables whose names are plain identifiers (letters, digits, _) | The framework's tables (_sqlx_migrations, zapline_sessions, zapline_jobs) and those passed to .exclude([...]) |
| Every other column, typed: checkboxes for booleans, number inputs for numbers, text areas for JSON and long text, binary columns as their size | Columns whose names contain password, secret or token or end in _hash, and those passed to .hide([...]) ("table.column" or "column"). Hidden columns are never read or written. |
Rows are edited by their primary key; tables without a single-column primary key are browse-only, and .read_only() makes the whole panel browse-only.
| Setting | Effect |
|---|---|
.path("/backoffice") | Serve it somewhere other than /admin |
.protect(middleware) | Put middleware in front of every admin route, and serve it in release builds and to other machines |
.exclude(["audit_log"]) | Leave tables out |
.hide(["users.email", "ip_address"]) | Leave columns out |
.read_only() | No create, edit or delete |
Lists and forms
The list shows 50 rows per page, newest first (by primary key). Click a column header to sort by it, again to reverse. The search box matches text, JSON and date columns case-insensitively (% and _ are literal), and a number also matches the primary key.
In the forms, an empty field means NULL when the column allows it, and the column's default when creating a row. Numbers are checked before anything is written. If the database refuses a change (a unique email taken, a missing required value), the form comes back with the database's message and what you typed.
With both postgres and sqlite enabled, admin uses Postgres; use admin_in::<sqlx::Sqlite>(..) for SQLite.
Translations (i18n)
With the i18n feature, an app can speak several languages. Put one TOML file per language in locales/, register them, and take a Locale in handlers:
# locales/en.toml
hello = "Hello, {name}!"
[inbox]
count.zero = "No messages"
count.one = "{count} message"
count.other = "{count} messages"
# locales/fr.toml
hello = "Bonjour, {name} !"
inbox.count.one = "{count} message"
inbox.count.other = "{count} messages"
#![allow(unused)] fn main() { use zapline::i18n::{I18n, Locale}; #[get("/hello/{name}")] async fn hello(t: Locale, name: Path<String>) -> String { t.t_with("hello", &[("name", &name.0)]) } App::new() .i18n(I18n::load("locales")?) .mount(hello) }
The file name is the language tag (en, fr, pt-BR), and nested tables become dotted keys (inbox.count.one). I18n::messages(lang, toml) adds a language from a string instead, for example one embedded with include_str!.
Under zapline dev, translations loaded with I18n::load are read again when a file in the directory changes, and open pages reload: no restart. A file with a syntax error keeps the last good messages (the error goes to stderr). Outside zapline dev (no ZAPLINE_DEV=1) they are loaded once.
Which language
For each request, Locale is the first available language from:
?lang=frin the URL. The app then remembers it in alangcookie (one year,SameSite=Lax), so a language switcher is just links:<a href="?lang=fr">Français</a>.- The
langcookie. - The
Accept-Languageheader, by its weights. - The default:
enif there is anen.toml, otherwise the first file alphabetically, or the one set with.default_lang("de").
Tags match exactly (ignoring case) or by their main part: pt-BR uses pt.toml when there is no pt-BR.toml, and de-AT uses de.toml.
Messages
| Call | Result |
|---|---|
t.t("key") | The message, as written |
t.t_with("hello", &[("name", &user.name)]) | {name} filled in; any Display value works. {{ is a literal { |
t.plural("inbox.count", n, &[]) | The plural form for n, with {count} filled in |
t.lang() | The language tag, for <html lang> or formatting |
A message missing in the visitor's language falls back to the default language, then to the key itself, so a gap shows on the page rather than failing it. I18n::missing() lists the gaps, which makes a good test:
#![allow(unused)] fn main() { #[test] fn every_language_is_complete() { let i18n = I18n::load("locales").unwrap(); assert_eq!(i18n.missing(), []); } }
The dev console shows each language with its message count and missing keys.
Plurals. plural picks key.one, key.few, key.many, key.other and so on by the language's rules (CLDR rules for integers, built in for English, French, Portuguese, Spanish, German, Russian, Ukrainian, Polish, Czech, Arabic, Japanese, Chinese, Korean and the many languages that follow one of their patterns). A key.zero message, when the language has one, is used for 0 in any language. All forms come from one language: if the visitor's language lacks the key, the default language's forms and rules are used.
In templates
With the views feature, pass the Locale into the context and call it:
#![allow(unused)] fn main() { #[get("/")] async fn index(views: Views, t: Locale) -> Result<Html<String>> { views.render("index.html", context! { t, unread: 3 }) } }
<html lang="{{ t.lang }}">
<h1>{{ t("hello", name=user.name) }}</h1>
<p>{{ t.plural("inbox.count", unread) }}</p>
Translated text is escaped like any other value.
If you cache pages with ResponseCache, the language is part of the page: cache only pages that don't depend on it, or vary the cache by language.
Authentication & authorization
Authenticate a group of routes with JWTs, then declare who may call what, next to the route:
#![allow(unused)] fn main() { use zapline::auth::{Authenticate, Claims, CurrentUser, JwtConfig}; use zapline::prelude::*; async fn me(CurrentUser(claims): CurrentUser<Claims>) -> String { format!("hello {}", claims.sub) } async fn purge_cache() -> &'static str { "purged" } fn app() -> App { let api = Routes::new() .get("/me", me) .delete("/cache", purge_cache) .require_roles(["admin"]) // authorization: declarative, next to the route .wrap(Authenticate::jwt()); // authentication: who is calling? App::new() .jwt(JwtConfig::hs256(std::env::var("JWT_SECRET").expect("JWT_SECRET"))) .nest("/api", api) } }
Authentication is split in two so each part can be swapped:
- Authentication (who is it?) is done by an
Authenticator, run by theAuthenticateroute middleware. It stores the user for theCurrentUser<U>extractor. - Authorization (may they?) is declared as route policies (
.require_auth(),.require_roles(..),.require_scopes(..)) and enforced byAuthenticate(with.authorize()) or byGuard.
Policies fail closed: a route with a policy can never become public because a middleware was forgotten or installed in the wrong place. See Policies fail closed.
Cargo features: everything here is always available except JWT, which needs jwt (it pulls in jsonwebtoken), and password hashing, which needs password. CSRF is in Cookies, sessions & CSRF.
Accounts in one command: zapline g auth
For apps with users who sign up with an email and password, generate the whole thing instead of assembling it. In a project with a database (the fullstack template, or any project with migrations/):
zapline g auth
| File | What it holds |
|---|---|
migrations/<ts>_create_users.sql | users table (unique lowercased email, Argon2id password hash), password_resets (hashed single-use tokens) and oauth_accounts (linked GitHub/Google identities) |
src/routes/auth.rs | POST /api/auth/signup, /login, /logout, /forgot, /reset, GET /api/auth/me; the CurrentUser extractor; sessions stored in the database; the admin panel opened to the accounts in ADMIN_EMAILS, once each owner has proved the address |
templates/auth/*.html | htmx pages at /signup, /login, /forgot and /reset (when templates/base.html exists): rejected posts come back as a 422 with a message next to each field and the email kept (passwords are never echoed); success redirects with a flash message (Forms) |
tests/auth.rs | Sign-up, log-in, log-out, wrong passwords, duplicates, weak input, password reset, the HTML forms, GitHub login against a stand-in provider, who gets into /admin |
It also enables zapline's cookies, mail, oauth and password features, adds SESSION_SECRET, SMTP_URL, MAIL_FROM, APP_URL, ADMIN_EMAILS and the GitHub/Google client variables to .env.example, and compiles Argon2 optimized in debug builds, so tests stay fast.
Password reset. POST /api/auth/forgot (or the /forgot page) emails a link to APP_URL/reset?token=... and answers the same whether or not the account exists; the email goes out after the response, so timing doesn't tell either. The token is 256 random bits, stored only as a SHA-256 digest, valid for an hour and usable once; using it sets the new password, logs the user in and voids the account's other reset links. While developing, without SMTP_URL, the emails appear in the dev inbox at /_dev/mail, so the whole flow works on your machine.
Log in with GitHub or Google. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET (or the GOOGLE_ pair) and the login and sign-up pages show "Continue with GitHub"; providers without credentials are simply left out. Register the callback APP_URL/auth/github/callback (or /auth/google/callback) with the provider. The first social login links the identity to the account with the same verified email, or creates an account without a usable password ("Forgot your password?" sets one); later logins use the link. Sign-up doesn't check the address, so someone could open an account with another person's email ahead of them: when the real owner then logs in with GitHub or Google, that account's password is dropped and its sessions end.
Admins. The accounts in ADMIN_EMAILS get into /admin only after the address is proved: by using a reset link sent to it, or by a GitHub/Google login with it. Until then they get 403 email_unverified, so signing up first with the boss's address gets an attacker nowhere. See Social login for the pieces underneath.
Protect a handler by taking the generated extractor; it answers 401 login_required when nobody is logged in:
#![allow(unused)] fn main() { use crate::routes::auth::CurrentUser; #[get("/api/notes")] async fn my_notes(db: Db, CurrentUser(user): CurrentUser) -> Result<Json<Vec<Note>>> { let notes = sqlx::query_as::<_, Note>("SELECT * FROM notes WHERE user_id = $1") .bind(user.id) .fetch_all(&*db) .await?; Ok(Json(notes)) } }
Take Option<CurrentUser> to serve visitors and users differently. The generated code is yours to change: it is about 200 lines with no framework magic.
What it does for you: passwords hashed with Argon2id on the blocking thread pool; a fresh session id at login (no session fixation); changing the password logs out every other session (a session_version column); sign-up, log-in and reset requests limited to 10 a minute per client IP (set TRUSTED_PROXIES behind a proxy); requests from other sites refused (SameOrigin, CSRF protection with nothing to add to forms); the same error and the same timing for an unknown email and a wrong password (password::verify_missing); sessions in the database, so logins survive restarts and deploys; HttpOnly, SameSite=Lax cookies, Secure in release builds. In production set SESSION_SECRET to 32 or more random bytes (openssl rand -base64 48): release builds refuse to start without it.
Passwords
With the password feature, zapline::auth::password hashes and checks passwords with Argon2id (OWASP parameters, PHC strings that carry their salt and parameters):
#![allow(unused)] fn main() { use zapline::auth::password; let hash = password::hash("correct horse battery staple").await?; assert!(password::verify("correct horse battery staple", &hash).await?); }
Both run on the blocking thread pool, since a hash takes tens of milliseconds by design. password::verify_missing(pw) spends the same time and returns false: call it when the account doesn't exist. hash_blocking and verify_blocking are the synchronous versions.
Social login (OAuth)
With the oauth feature, App::oauth adds "log in with GitHub/Google/..." using the authorization-code flow with PKCE. Register a provider, then handle its callback with the OAuthLogin extractor:
#![allow(unused)] fn main() { use zapline::auth::oauth::{OAuth, OAuthLogin}; #[get("/auth/github/callback")] async fn github_done(login: OAuthLogin, session: Session) -> Result<Redirect> { // login.user: provider, id, email, email_verified, name, avatar_url, raw let user_id = find_or_create_user(&login.user).await?; session.regenerate(); session.insert("user_id", user_id)?; Ok(Redirect::to(login.next.as_deref().unwrap_or("/"))) } let app = App::new() .oauth(OAuth::from_env("github")?) // GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET .mount(github_done); }
Send people to /auth/github (optionally ?next=/settings) to start. That route redirects to the provider with a random state and a PKCE challenge, kept in a short-lived HttpOnly cookie scoped to /auth. On the way back, OAuthLogin checks the state, exchanges the code (sending the PKCE verifier) and fetches the profile. It answers 400 invalid_oauth_state for a forged or replayed callback, 401 oauth_denied when the user cancelled at the provider, 401 oauth_failed when the provider rejects the code, and 404 for a provider that isn't registered. next is kept only when it is a local path, so it can't be used as an open redirect.
| Provider | Constructor | Notes |
|---|---|---|
| GitHub | OAuth::github(id, secret) | Scope read:user user:email; the email is the verified primary one from /user/emails |
OAuth::google(id, secret) | OpenID Connect userinfo; scopes openid email profile | |
| Any OAuth 2 provider | OAuth::custom(name, authorize_url, token_url, userinfo_url) | Reads sub/id, email, email_verified, name, picture/avatar_url; the whole profile is in user.raw |
.scopes([...]) replaces the scopes, .redirect_url(url) overrides the callback URL (by default APP_URL + /auth/{name}/callback, or the request's host when APP_URL is unset), and .endpoints(..) points a built-in provider at a test double. Only trust user.email for account linking when user.email_verified is true.
Credential extractors
Three extractors read credentials without any setup. Missing or malformed credentials are rejected with 401, a WWW-Authenticate challenge and a stable error code: missing_credentials or invalid_credentials.
| Extractor | Header | Challenge | Check with |
|---|---|---|---|
BearerToken | Authorization: Bearer <token> | Bearer | token.token() and constant_time_eq |
BasicAuth | Authorization: Basic <base64> | Basic realm="Restricted", charset="UTF-8" | auth.verify(user, pass) |
ApiKey | x-api-key: <key> | ApiKey header="x-api-key" | key.verify(expected), key.verify_any(keys) |
Use Option<BearerToken> (or any of the others) for optional credentials. The extractors never compare secrets for you. Do it in constant time:
#![allow(unused)] fn main() { use zapline::auth::{constant_time_eq, ApiKey, BasicAuth, BearerToken}; use zapline::prelude::*; async fn admin(token: BearerToken) -> Result<&'static str> { if !constant_time_eq(token.token(), "let-me-in") { return Err(Error::forbidden()); } Ok("welcome") } async fn metrics(auth: BasicAuth) -> Result<&'static str> { if !auth.verify("ops", "correct horse battery staple") { return Err(auth.reject()); // 401 invalid_credentials + Basic challenge } Ok("# metrics") } async fn report(key: ApiKey) -> Result<&'static str> { if !key.verify_any(["k-123", "k-456"]) { return Err(key.reject()); } Ok("report") } }
constant_time_eq(a, b) compares byte strings without leaking their contents through timing (only a length difference is visible). BasicAuth::verify and ApiKey::verify_any always run every comparison. All three types redact their secrets in Debug output.
Customize where the credentials come from by registering a config as state:
#![allow(unused)] fn main() { use zapline::auth::{ApiKeyConfig, BasicAuthConfig}; use zapline::prelude::*; fn app() -> App { App::new() .state(BasicAuthConfig::new("admin area")) // realm in the challenge .state(ApiKeyConfig::new().header("x-token").query_param("api_key")) // keys in URLs end up in logs: prefer headers } }
JWT
Needs the jwt feature. Register a JwtConfig with App::jwt, then either extract claims with Jwt<C> or authenticate a group with Authenticate::jwt().
#![allow(unused)] fn main() { use std::time::Duration; use zapline::auth::{Claims, Jwt, JwtConfig}; use zapline::prelude::*; // Issue a token (for example in a login handler). async fn login(State(jwt): State<JwtConfig>) -> Result<String> { let claims = Claims::new("user-1") .roles(["admin"]) .scopes(["reports:read"]) .claim("tenant", "acme"); jwt.encode(&claims) } // Verify one: the `Jwt` extractor validates the bearer token and yields its claims. async fn whoami(Jwt(claims): Jwt) -> String { claims.sub } fn app() -> App { let jwt = JwtConfig::hs256("an-example-secret-of-at-least-32-bytes") .issuer("https://auth.example.com") .audience("api") .ttl(Duration::from_secs(15 * 60)); App::new().jwt(jwt).post("/login", login).get("/whoami", whoami) } }
Jwt<C> defaults to Claims. Use Jwt<MyClaims> for your own claims type (C: Deserialize). A missing token is 401 missing_credentials, an expired one 401 token_expired and anything else wrong 401 invalid_token, all with a Bearer challenge. Without App::jwt(..) the extractor fails with 500 auth_misconfigured.
JwtConfig
| Constructor | Algorithm |
|---|---|
JwtConfig::hs256(secret), hs384, hs512 | HMAC with a shared secret. Panics if the secret is shorter than 32 bytes. |
JwtConfig::rs256_pem(public_pem, Some(private_pem)) | RSA. Pass None to verify tokens without being able to issue them. |
JwtConfig::es256_pem(public_pem, Some(private_pem)) | ECDSA P-256 (PKCS#8 private key) |
JwtConfig::try_rs256_pem / try_es256_pem | Fallible versions that return an error instead of panicking on a bad key |
JwtConfig::new(algorithm, decoding_key, encoding_key) | Full control over algorithm and keys |
JwtConfig::from_key(jwt_key) | Starts from a named JwtKey, which also covers EdDSA (Ed25519) |
JwtConfig::from_jwks(Jwks::new(url)) | Verify-only, with the keys an identity provider publishes (JWKS, feature jwks) |
| Builder method | Effect |
|---|---|
.issuer(iss) | Tokens must carry this iss. Issued tokens get it. |
.audience(aud) | An accepted audience (call again to accept several). Tokens must carry a matching aud. Issued tokens get the first one. |
.ttl(duration) | Lifetime of issued tokens when the claims have no exp (default 1 hour) |
.leeway(duration) | Clock-skew tolerance for exp and nbf (default 60 s) |
.required_claims(names) | Claims that must be present, from exp, nbf, iss, aud, sub (default: exp) |
.key_id(kid) | Names the config's key: issued tokens carry this kid, and tokens naming it verify with it (key rotation) |
.jwks(Jwks::new(url)) | Also accept tokens signed with a remote JWK Set's keys (feature jwks) |
.validation(|v| ..) | Adjust the underlying jsonwebtoken::Validation |
The configured algorithm is pinned, so a token cannot choose its own (no alg: none, no HS/RS confusion). With several keys, each key keeps its own algorithm: a token naming an RS256 key with alg: HS256 is rejected. exp is required and validated, nbf is validated when present, and tokens longer than 16 KiB are rejected before parsing. encode fills in a missing iat, exp (now + ttl), iss and aud.
Claims
| Field | Notes |
|---|---|
sub, exp, iat, nbf, iss, aud | Registered claims. aud accepts a string or an array. |
roles | A string or an array. Checked by .require_roles(..). |
scopes | An array, or the OAuth scope / scp claim as a space-separated string. Checked by .require_scopes(..). |
extra | Every other claim. Read it with claims.get::<T>("tenant"). |
Build claims with Claims::new(sub), then .roles(..), .scopes(..), .claim(key, value), .expires_in(duration), .expires_at(unix_secs), .issuer(..) and .audience(..). claims.has_role("admin") and claims.has_scope("x") are available too.
For your own claims type, implement HasRoles and use JwtAuthenticator:
#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; use zapline::auth::{Authenticate, CurrentUser, HasRoles, JwtAuthenticator}; use zapline::prelude::*; #[derive(Clone, Serialize, Deserialize)] struct AppClaims { sub: String, exp: u64, #[serde(default)] roles: Vec<String>, tenant: String, } impl HasRoles for AppClaims { fn roles(&self) -> &[String] { &self.roles } } async fn tenant(CurrentUser(c): CurrentUser<AppClaims>) -> String { c.tenant } fn api() -> Routes { Routes::new() .get("/tenant", tenant) .wrap(Authenticate::new(JwtAuthenticator::<AppClaims>::new()).required().authorize()) } }
Key rotation
A JwtConfig holds a key ring: the key that signs, and every key that still verifies. Tokens name their key in the kid header, and the ring picks the key by that name. A JwtKey is one entry: a kid, an algorithm and the key material.
JwtKey constructor | Algorithm |
|---|---|
JwtKey::hs256(kid, secret) | HMAC-SHA256 (errors if the secret is shorter than 32 bytes) |
JwtKey::rs256_pem(kid, public_pem, Some(private_pem)) | RSA |
JwtKey::es256_pem(kid, public_pem, Some(private_pem)) | ECDSA P-256 |
JwtKey::ed25519_pem(kid, public_pem, Some(private_pem)) | EdDSA (Ed25519) |
JwtKey::from_jwk(&jwk) | A public JWK (RSA, EC P-256/P-384, Ed25519). Symmetric and encryption keys are refused. |
JwtKey::new(kid, algorithm, decoding_key, encoding_key) | Full control |
Pass None for the private key to get a key that only verifies. Three methods change the ring while the app runs. Every clone of the config shares the ring, so calling them on the State<JwtConfig> a handler extracted takes effect for the whole app:
| Method | Effect |
|---|---|
jwt.rotate(key) | Sign with key from now on. The previous signing key stays and keeps verifying the tokens it issued. Errors if key has no private part or no kid. |
jwt.add_key(key) | Add a key that only verifies: an old key kept after a restart, or the next key published before you start signing with it |
jwt.retire(kid) | Stop accepting a key's tokens. The current signing key can't be retired. |
jwt.key_ids() | The kids in the ring, the signing key's first |
#![allow(unused)] fn main() { use zapline::auth::{Claims, JwtConfig, JwtKey}; use zapline::prelude::*; fn jwt() -> Result<JwtConfig> { let current = std::env::var("JWT_SECRET")?; let jwt = JwtConfig::hs256(current).key_id("2026-10"); // Tokens signed with last month's key keep working until they expire. if let Ok(previous) = std::env::var("JWT_SECRET_PREVIOUS") { jwt.add_key(JwtKey::hs256("2026-09", previous)?); } Ok(jwt) } // Rotate without a restart, for example from an admin endpoint. async fn rotate(State(jwt): State<JwtConfig>) -> Result<&'static str> { let next = std::env::var("JWT_SECRET_NEXT")?; jwt.rotate(JwtKey::hs256("2026-11", next)?)?; Ok("rotated") } }
How a key is picked:
- If the token's
kidnames a key in the ring, only that key is tried. The key's algorithm must match the token'salg. - Keys without a
kid(a config built without.key_id(..)) accept anykidwith their algorithm, so configs that predate key ids keep working. - A token without
kidverifies only when the choice is unambiguous: a key without akid, or a ring with a single key. - Otherwise the token is
401 invalid_token, or it is looked up in the remote JWK Set when one is configured.
A config with a single unnamed key (the usual JwtConfig::hs256(secret)) verifies without reading the token header, as before. Rotations live in memory. When several instances sign tokens, give each instance the same keys at startup with add_key, or let one service issue tokens and have the others follow its JWK Set.
Publishing your keys
App::serve_jwks(path) serves the public half of every RSA, EC and Ed25519 key in the registered config as a JWK Set (RFC 7517). HMAC keys are never published. The set is read on every request, so a rotation shows up at once. Responses carry Cache-Control: public, max-age=300, and the route is left out of the OpenAPI document.
#![allow(unused)] fn main() { use zapline::auth::{JwtConfig, JwtKey}; use zapline::prelude::*; fn app() -> Result<App> { let public = std::fs::read("keys/2026-10.pub.pem")?; let private = std::fs::read("keys/2026-10.pem")?; let key = JwtKey::es256_pem("2026-10", &public, Some(&private))?; Ok(App::new() .jwt(JwtConfig::from_key(key).issuer("https://auth.example.com")) .serve_jwks("/.well-known/jwks.json")) } }
jwt.public_jwks() returns the same set (a JwkSet) if you serve it some other way.
To rotate without failing any request, publish the next key first with jwt.add_key(next_public_key). Wait at least as long as verifiers cache the set, then switch with jwt.rotate(next_key). Zapline verifiers don't need the wait: they refetch the set when a token names a key they haven't seen.
Tokens from an identity provider (JWKS)
With the jwks feature, a config can verify tokens signed by keys that an identity provider publishes at its jwks_uri: Auth0, Okta, Keycloak, Cognito, Entra ID, Google, or another zapline app's serve_jwks.
#![allow(unused)] fn main() { use std::time::Duration; use zapline::auth::{Authenticate, Jwks, JwtConfig}; use zapline::prelude::*; fn app() -> App { let issuer = "https://example.eu.auth0.com/"; let jwt = JwtConfig::from_jwks(Jwks::discover(issuer)) .issuer(issuer) .audience("https://api.example.com"); App::new() .jwt(jwt) .nest("/api", Routes::new().get("/me", || async { "hi" }).wrap(Authenticate::jwt())) } }
Jwks::new(url) takes the JWK Set URL directly. Jwks::discover(issuer) reads jwks_uri from {issuer}/.well-known/openid-configuration (OpenID Connect Discovery) and checks that the document names the same issuer. JwtConfig::hs256(secret).jwks(..) combines your own keys with the provider's: local kids are checked first and never cause a fetch.
How the cache behaves:
- Startup.
App::jwtfetches the set before the server starts accepting requests. This is best effort: if the provider is down, the failure is logged and the first request tries again. - TTL. Keys are fresh for
.ttl(..)(default 10 minutes). After that, the next request still verifies with the cached keys while a background task refreshes them. No request waits for a refresh of keys it already has. - Unknown
kid. A token naming a key the cache doesn't have triggers a refresh, so a provider's rotation is picked up at once. Refreshes happen at most once per.min_refresh_interval(..)(default 30 s), however many made-upkids arrive. Concurrent requests share one fetch. - Timeouts. A request waits at most
.timeout(..)(default 5 s) for a fetch. - Failures. On an error status, malformed JSON, a set with no usable keys, or a timeout, the cached keys are kept and keep verifying (stale on error). A token whose key the cache can't have while the provider is failing gets
503 jwks_unavailable, not a misleading401.
The algorithm comes from the JWK's alg, or from its key type when alg is absent: RS256 for RSA, ES256/ES384 for P-256/P-384, EdDSA for Ed25519. Keys whose alg doesn't fit their key type, symmetric (oct) keys, encryption keys ("use": "enc") and unknown key types are skipped. .algorithms([Algorithm::RS256]) narrows the set further. HMAC algorithms are never accepted from a remote set, so a public key can't be turned into an HMAC secret.
Jwks method | Effect |
|---|---|
Jwks::new(url) / Jwks::discover(issuer) | Where the keys come from |
.ttl(d) | How long fetched keys are fresh (default 10 min) |
.min_refresh_interval(d) | Fetch at most this often (default 30 s) |
.timeout(d) | The longest a request waits for a fetch (default 5 s) |
.algorithms([..]) | Accept only keys for these algorithms |
jwks.refresh().await | Fetch now. Returns the number of usable keys. |
jwks.key_ids() | The cached kids |
jwt.decode(token) is synchronous and uses only cached keys. A key it is missing is fetched in the background. The Jwt extractor, Authenticate::jwt() and jwt.verify(token).await wait for the fetch.
Custom authenticators
Anything that can turn a request into a user (sessions, API keys looked up in a database, mTLS, an external identity provider) is an Authenticator. Return:
Ok(None)when the request carries no credentials for this scheme,Ok(Some(user))when they are valid,Err(..), typically a401, when they are present but invalid.
#![allow(unused)] fn main() { use zapline::auth::{constant_time_eq, Authenticate, Authenticator, CurrentUser, HasRoles}; use zapline::prelude::*; #[derive(Clone)] struct User { name: String, roles: Vec<String>, } impl HasRoles for User { fn roles(&self) -> &[String] { &self.roles } } struct StaticKeys; impl Authenticator for StaticKeys { type User = User; async fn authenticate(&self, req: &Request) -> Result<Option<User>> { let Some(key) = req.headers().get("x-api-key") else { return Ok(None); }; if constant_time_eq(key.as_bytes(), "k-admin") { return Ok(Some(User { name: "ops".into(), roles: vec!["admin".into()] })); } Err(Error::unauthorized().with_code("invalid_credentials")) } } async fn who(CurrentUser(user): CurrentUser<User>) -> String { user.name } fn admin() -> Routes { Routes::new() .get("/stats", || async { "stats" }) .require_roles(["admin"]) .get("/who", who) .wrap(Authenticate::new(StaticKeys).authorize()) } }
A closure Fn(&Request) -> impl Future<Output = Result<Option<User>>> works as an authenticator too:
#![allow(unused)] fn main() { use zapline::auth::{Authenticate, CurrentUser}; use zapline::prelude::*; fn api() -> Routes { let auth = Authenticate::new(|req: &Request| { let user = req.headers().get("x-user").and_then(|v| v.to_str().ok()).map(str::to_owned); async move { Ok(user) } }) .required(); Routes::new() .get("/me", |CurrentUser(name): CurrentUser<String>| async move { name }) .wrap(auth) } }
Authenticate
Authenticate is route middleware (install it with Routes::wrap or App::wrap_route). On each request:
- No credentials: the request passes through anonymously, unless
.required()is set or the route has a policy. Then it is rejected with401 missing_credentialsand the authenticator's challenge. - Invalid credentials: rejected with the authenticator's error, even when authentication is optional. The exception is a route marked
.allow_anonymous(), where the error is ignored. - Valid credentials: the user is stored and
CurrentUser<U>works in the handler. With.authorize(), the route's role and scope policies are checked too (403 forbidden).
| Method | Effect |
|---|---|
Authenticate::new(authenticator) | Anonymous requests are allowed on routes without a policy |
.required() / .optional() | Reject unauthenticated requests on every route this wraps (except .allow_anonymous()), or allow them again |
.authorize() | Also enforce .require_roles(..) and .require_scopes(..). The user type must implement HasRoles. |
.challenge(value) | The WWW-Authenticate value for 401s (the authenticator can provide a default through Authenticator::challenge) |
Authenticate::jwt() / jwt_with(config) | JwtAuthenticator<Claims>, .required() and .authorize() in one call |
CurrentUser<U> derefs to U. It rejects with 401 missing_credentials when there is no user. Use Option<CurrentUser<U>> where anonymous access is fine. U is cloned on every extraction, so wrap big users in an Arc (Arc<T> implements HasRoles when T does).
Policies
Declare requirements on the route they protect. Builder methods apply to the most recently registered route, on Routes and on App:
| Method | Requirement |
|---|---|
.require_auth() | Any authenticated user |
.require_roles(["admin", "ops"]) | The user needs at least one of these roles |
.require_scopes(["users:read", "users:write"]) | The user needs all of these scopes |
.allow_anonymous() | The route is public. Group requirements and every role/scope policy are skipped. Valid credentials still populate CurrentUser. |
.require_auth_all(), .require_roles_all([..]), .require_scopes_all([..]) | The same, for every route in a group (on Routes) |
#![allow(unused)] fn main() { use zapline::auth::{Authenticate, Claims, CurrentUser}; use zapline::prelude::*; async fn me(CurrentUser(c): CurrentUser<Claims>) -> String { c.sub } fn api() -> Routes { Routes::new() .get("/me", me) .get("/reports", || async { "reports" }) .require_scopes(["reports:read"]) .delete("/users/{id}", || async { StatusCode::NO_CONTENT }) .require_roles(["admin"]) .get("/status", || async { "up" }) .allow_anonymous() .wrap(Authenticate::jwt()) } fn admin_area() -> Routes { Routes::new() .get("/stats", || async { "stats" }) .get("/audit", || async { "audit" }) .require_roles(["auditor"]) // replaces the group's roles for this route .require_roles_all(["admin"]) .wrap(Authenticate::jwt()) } }
Rules:
- For each kind of requirement, the most specific declaration wins. A route's own
.require_roles(..)replaces its group's.require_roles_all(..), while a group's role requirement and a route's scope requirement both apply. - Calling
.require_roles(..)or.require_scopes(..)repeatedly on one route adds to the list. - Failing a check gives
403with the codeforbidden. A missing user gives401 missing_credentials. - Policies are plain route metadata, so tooling can read them:
RoutePolicy::of(route)has.requires_auth(),.roles(),.scopes(),.is_anonymous()and.allows(&user). - Policies don't change the OpenAPI document. Add
security = "bearer"orRoutes::security_all("bearer")separately; see OpenAPI & interactive docs.
Policies fail closed
A declarative policy is only as good as the middleware that enforces it. A common mistake in other frameworks is to declare @RolesAllowed("admin") and forget to install the filter, which silently makes the endpoint public. zapline rules that out: every .require_*() call also attaches an innermost enforcement layer to the route. It runs right before the handler, after all group and route middleware, and it checks that something has actually verified the policy:
| Situation | Response |
|---|---|
| A policy, but no authentication middleware ran | 401 missing_credentials |
A user was authenticated, the policy has only .require_auth() | Allowed |
| A user was authenticated, the policy has roles or scopes, but no middleware checked them | 500 auth_misconfigured |
The user lacks the role or scope (checked by Authenticate::authorize() or Guard) | 403 forbidden |
The route is .allow_anonymous() | Policy skipped |
The 500 is a loud signal, not a security hole. The request never reaches your handler. The response body names the route and the missing piece (call Authenticate::authorize() ...), so it shows up in the first test you write.
Why a global App::wrap(Authenticate..) cannot check roles
Global middleware (App::wrap) runs before routing, so while it runs there is no matched route yet and therefore no policy to check against. A global Authenticate can still identify the user (CurrentUser works, and .require_auth() is satisfied), but a route with roles or scopes behind it answers 500 auth_misconfigured.
Check roles in middleware that runs after routing. All of these work:
#![allow(unused)] fn main() { use zapline::auth::{Authenticate, Claims, Guard, JwtConfig}; use zapline::prelude::*; fn secret() -> JwtConfig { JwtConfig::hs256("an-example-secret-of-at-least-32-bytes") } // 1. A group: authentication and authorization for exactly these routes. fn grouped() -> App { let admin = Routes::new() .get("/stats", || async { "stats" }) .require_roles(["admin"]) .wrap(Authenticate::jwt()); App::new().jwt(secret()).nest("/admin", admin) } // 2. Route middleware for the whole app (runs after routing for every matched route). fn route_wide() -> App { App::new() .jwt(secret()) .wrap_route(Authenticate::jwt().optional()) .get("/stats", || async { "stats" }) .require_roles(["admin"]) } // 3. Identify globally, enforce after routing. fn split() -> App { App::new() .jwt(secret()) .wrap(Authenticate::jwt().optional()) .wrap_route(Guard::<Claims>::policies()) .get("/stats", || async { "stats" }) .require_roles(["admin"]) } }
Prefer a group when only part of the app is protected. App::wrap_route also wraps the routes that plugins add (docs, /health, /metrics). That is why the examples above use .optional() there: a .required() authenticator would lock your health checks. Anything that must stay public can be marked .allow_anonymous() on your own routes.
Guard
Guard<U> is the other half of authorization: route middleware that checks the CurrentUser<U> set by an earlier Authenticate. It must run after routing and after authentication (.wrap(auth).wrap(guard), or App::wrap_route together with a global Authenticate). Used before routing, it answers 500 auth_misconfigured.
| Constructor | Rule |
|---|---|
Guard::policies() | Enforces each route's own policies, for an Authenticate that doesn't call .authorize(). Routes without a policy pass. U: HasRoles. |
Guard::roles([..]) | At least one of these roles on every route it wraps. U: HasRoles. |
Guard::scopes([..]) | All of these scopes on every route it wraps. U: HasRoles. |
Guard::new(|user, route| bool) | Your own rule. It receives the user and the matched RouteInfo, including its metadata. |
Routes marked .allow_anonymous() are skipped. A request without a user gets 401, a failed rule 403.
#![allow(unused)] fn main() { use zapline::auth::{Authenticate, Guard, HasRoles}; use zapline::prelude::*; use zapline::RouteInfo; #[derive(Clone)] struct User { id: u64, roles: Vec<String>, } impl HasRoles for User { fn roles(&self) -> &[String] { &self.roles } } fn api() -> Routes { let auth = Authenticate::new(|_: &Request| async { Ok(Some(User { id: 1, roles: vec![] })) }); // Custom rule: only user 1 may call DELETE routes. let guard = Guard::new(|user: &User, route: &RouteInfo| { route.method() != Some(&Method::DELETE) || user.id == 1 }); Routes::new() .delete("/things/{id}", || async { StatusCode::NO_CONTENT }) .wrap(auth) .wrap(guard) } }
HasRoles
Roles and scopes are plain strings. Any user type becomes usable with policies by implementing roles() and, optionally, scopes():
#![allow(unused)] fn main() { use zapline::auth::HasRoles; #[derive(Clone)] struct User { roles: Vec<String>, scopes: Vec<String>, } impl HasRoles for User { fn roles(&self) -> &[String] { &self.roles } fn scopes(&self) -> &[String] { &self.scopes } } }
has_role(name) and has_scope(name) come for free.
Testing authorization
Send tokens with the test client's .bearer(..) and assert on the status. Test the unhappy paths as well as the happy ones:
#![allow(unused)] fn main() { use zapline::auth::{Authenticate, Claims, JwtConfig}; use zapline::prelude::*; fn app() -> App { let admin = Routes::new() .get("/stats", || async { "stats" }) .require_roles(["admin"]) .wrap(Authenticate::jwt()); App::new() .jwt(JwtConfig::hs256("an-example-secret-of-at-least-32-bytes")) .nest("/admin", admin) } #[tokio::test] async fn only_admins_see_stats() { let client = app().test(); let jwt = JwtConfig::hs256("an-example-secret-of-at-least-32-bytes"); let admin = jwt.encode(&Claims::new("ada").roles(["admin"])).unwrap(); let user = jwt.encode(&Claims::new("bob").roles(["user"])).unwrap(); assert_eq!(client.get("/admin/stats").send().await.status(), 401); assert_eq!(client.get("/admin/stats").bearer(&user).send().await.status(), 403); assert_eq!(client.get("/admin/stats").bearer(&admin).send().await.status(), 200); } }
Cookies, sessions & CSRF
Cookies, server-side sessions and CSRF protection for browser-facing apps. All of it needs the Cargo feature cookies (CSRF included; it adds the cookie, getrandom, hmac and sha2 crates). The common case is a session behind a signed cookie:
#![allow(unused)] fn main() { use zapline::cookies::Key; use zapline::prelude::*; use zapline::session::{MemoryStore, Session, SessionLayer}; async fn login(session: Session) -> Result<&'static str> { session.regenerate(); // new session id on login: prevents fixation session.insert("user_id", 42)?; Ok("logged in") } async fn me(session: Session) -> Result<String> { let id: u64 = session.get("user_id").ok_or_else(Error::unauthorized)?; Ok(format!("user {id}")) } async fn logout(session: Session) -> &'static str { session.destroy(); "bye" } fn app() -> App { App::new() .cookie_key(Key::generate()) // use a stable key in production .wrap(SessionLayer::new(MemoryStore::new())) .post("/login", login) .get("/me", me) .post("/logout", logout) } }
Cookies
CookieJar is an extractor holding the request's cookies. Changes made with add and remove are sent back as Set-Cookie headers when you return the jar from the handler: jar, (jar, body) or (StatusCode, jar, body). Only changes are sent. Forgetting to return the jar loses the change (the methods are #[must_use]).
#![allow(unused)] fn main() { use zapline::cookies::{secure_cookie, CookieJar}; use zapline::prelude::*; async fn visit(jar: CookieJar) -> (CookieJar, String) { let n: u32 = jar.get("visits").and_then(|c| c.value().parse().ok()).unwrap_or(0) + 1; (jar.add(secure_cookie("visits", n.to_string())), format!("visit #{n}")) } async fn forget(jar: CookieJar) -> (CookieJar, StatusCode) { (jar.remove("visits"), StatusCode::NO_CONTENT) } }
secure_cookie(name, value) returns a CookieBuilder with safe defaults: HttpOnly, Secure, SameSite=Lax and Path=/. Adjust it with the builder methods, for example secure_cookie("theme", "dark").max_age(zapline::cookies::time::Duration::days(30)). The cookie crate's types are re-exported from zapline::cookies: Cookie, CookieBuilder, SameSite, Key, Expiration and time.
Cookie values are percent-encoded. At most 256 cookies are read per request, and if a name appears twice the first occurrence wins. remove(..) defaults to Path=/; pass a full cookie if you set another path or domain.
Signed and private jars
| Jar | Protection | The client can... |
|---|---|---|
CookieJar | None | read and change it |
SignedCookieJar | HMAC-SHA256 signature | read it, but not change it |
PrivateCookieJar | AES-256-GCM encryption | neither read nor change it |
Both protected jars need a Key registered with App::cookie_key; without one they fail with 500 cookie_key_missing. get returns None for cookies that are missing or have been tampered with. They use the same API as CookieJar:
#![allow(unused)] fn main() { use zapline::cookies::{Key, PrivateCookieJar}; use zapline::prelude::*; async fn remember(jar: PrivateCookieJar) -> (PrivateCookieJar, &'static str) { (jar.add(("prefs", "dark-mode")), "saved") } async fn prefs(jar: PrivateCookieJar) -> String { jar.get("prefs").map(|c| c.value().to_owned()).unwrap_or_else(|| "none".into()) } fn app() -> App { // 64 random bytes from your secret store, identical on every instance. let secret = std::env::var("COOKIE_KEY").expect("COOKIE_KEY"); let key = Key::try_from(secret.as_bytes()).expect("COOKIE_KEY must be at least 64 bytes"); App::new().cookie_key(key).post("/prefs", remember).get("/prefs", prefs) } }
Key::generate() creates a random key. That's fine for development, but every restart invalidates all signed and private cookies and all sessions, and each instance would use a different key. In production, load a stable key of at least 64 bytes. The same key signs sessions and CSRF tokens and protects the jars.
Sessions
A session is a map of JSON values stored on the server, found through a signed cookie that contains only a random 256-bit session id. Install SessionLayer (globally with App::wrap, or on a group with Routes::wrap), then extract Session in handlers:
Session method | Effect |
|---|---|
get::<T>(key) | The value deserialized, or None if missing or of another shape |
get_value(key) | The raw serde_json::Value |
insert(key, value) | Stores any Serialize value. Returns an error if serialization fails. |
remove(key) | Removes and returns a value |
contains_key(key), keys(), is_empty() | Inspection |
clear() | Removes all values, keeping the session |
regenerate() | New session id, same data. Call it on login and on other privilege changes. |
destroy() | Deletes the data from the store and tells the client to drop the cookie. Values inserted afterwards start a new session. |
id(), expires_at() | The id and expiry of the stored session |
Session::current(&req) | The session from inside middleware or an authenticator, where extractors can't be used |
Session is cheap to clone and all clones share the same data. Changes are saved after the handler returns.
How it behaves:
- Lazy. The store is only touched when a handler extracts
Session. Routes that never use it cost one clone of theCookieheader. - Saved only when needed. Unchanged sessions aren't written, except to extend the expiry.
- Sliding expiry. A session expires after the idle timeout (
ttl, default 24 hours). It is saved again, with a fresh cookie, when less than half of that remains. - Fixation-proof. An unknown or expired id sent by the client is never adopted: you get a new session, and a new id is issued when data is stored. An empty new session is never stored.
- Store outages don't log users out. If the store fails to load the session, every use of it in that request fails with
503and the codesession_store_unavailable(also when an earlier use, such as an authenticator on an.allow_anonymous()route, swallowed the first error). The request never falls back to a new, empty session, and its response doesn't touch the session cookie, so the user is still signed in once the store recovers. - Without the layer, extracting
Sessionfails with500 session_layer_missing. Without a cookie key, the layer fails with500 cookie_key_missing.
SessionLayer options
| Method | Default | Effect |
|---|---|---|
SessionLayer::new(store) | Sessions kept in store | |
.cookie_name(name) | zapline.sid | Cookie name (panics on an invalid token) |
.ttl(duration) | 24 hours | Idle timeout |
.secure(bool) | true | Secure attribute. Browsers drop Secure cookies on plain http:// (most make an exception for localhost). Disable it only for local development. |
.http_only(bool) | true | HttpOnly attribute |
.same_site(SameSite) | Lax | SameSite attribute |
.path(path) / .domain(domain) | / / host-only | Cookie scope |
.key(key) | the app's key | Sign with this key instead of App::cookie_key |
Stores
MemoryStore keeps sessions in the process. It is meant for development, tests and single-instance apps: sessions vanish on restart and aren't shared between replicas. It holds at most 100,000 sessions by default (MemoryStore::with_capacity(n) changes that), purging expired ones as new ones arrive and evicting the sessions closest to expiry when full.
With a database (Databases), keep sessions there: they survive restarts and are shared by every instance. App::database_sessions creates the zapline_sessions table at startup and installs the session layer (register the database first):
#![allow(unused)] fn main() { use zapline::cookies::key_from_env; use zapline::db::Database; App::new() .database(Database::from_env()) .cookie_key(key_from_env("SESSION_SECRET")?) .database_sessions(|layer| layer.secure(!cfg!(debug_assertions))) }
key_from_env(var) derives the cookie key from a secret of 32 or more bytes. When the variable is unset, debug builds use a fixed development key (so sessions survive zapline dev restarts) and release builds fail. key_from_secret(s) does the same derivation from a string you load yourself.
For anything else, implement SessionStore over Redis or whatever you run. It has three async methods. Ids are 256-bit random URL-safe strings, so they are safe to use as keys directly:
#![allow(unused)] fn main() { use std::collections::HashMap; use std::sync::Mutex; use zapline::prelude::*; use zapline::session::{SessionLayer, SessionRecord, SessionStore}; #[derive(Default)] struct MapStore { rows: Mutex<HashMap<String, SessionRecord>>, } impl SessionStore for MapStore { async fn load(&self, id: &str) -> Result<Option<SessionRecord>> { // Return None for unknown ids. Expired rows may be returned too: // the layer checks `expires_at`. Ok(self.rows.lock().unwrap().get(id).cloned()) } async fn save(&self, id: &str, record: &SessionRecord) -> Result<()> { // Keep the row until `record.expires_at` (for example, `SET .. EXAT` in Redis). self.rows.lock().unwrap().insert(id.to_owned(), record.clone()); Ok(()) } async fn delete(&self, id: &str) -> Result<()> { self.rows.lock().unwrap().remove(id); Ok(()) } } fn layer() -> SessionLayer { SessionLayer::new(MapStore::default()) } }
A SessionRecord has two fields: data (a JSON map) and expires_at (a SystemTime). An error from load becomes 503 session_store_unavailable, with your error kept as its source for the logs. Errors from save and delete propagate to the client as the Error you return (usually map them to a 503 or 500).
Sessions as authentication
Authenticate (see Authentication & authorization) can turn the session into a CurrentUser. The session layer must run first, so install it globally and the authenticator on the group:
#![allow(unused)] fn main() { use zapline::auth::{Authenticate, Authenticator, CurrentUser}; use zapline::cookies::Key; use zapline::prelude::*; use zapline::session::{MemoryStore, Session, SessionLayer}; struct SessionAuth; impl Authenticator for SessionAuth { type User = String; async fn authenticate(&self, req: &Request) -> Result<Option<String>> { let session = Session::current(req).await?; Ok(session.get::<String>("user")) } } async fn dashboard(CurrentUser(user): CurrentUser<String>) -> String { format!("welcome back, {user}") } fn app() -> App { let private = Routes::new() .get("/dashboard", dashboard) .wrap(Authenticate::new(SessionAuth).required()); App::new() .cookie_key(Key::generate()) .wrap(SessionLayer::new(MemoryStore::new())) .nest("/app", private) } }
Testing sessions
.with_cookies() gives the test client a browser-like cookie store, so a login flow is one line per step (see Testing):
#![allow(unused)] fn main() { use zapline::cookies::Key; use zapline::prelude::*; use zapline::session::{MemoryStore, Session, SessionLayer}; fn app() -> App { App::new() .cookie_key(Key::generate()) .wrap(SessionLayer::new(MemoryStore::new())) .post("/login", |session: Session| async move { session.insert("user_id", 42)?; Ok::<_, Error>("ok") }) .get("/me", |session: Session| async move { session.get::<u64>("user_id").map(|id| format!("user {id}")).unwrap_or_default() }) } #[tokio::test] async fn session_survives_a_round_trip() { let browser = app().test().with_cookies().base_url("https://app.example.com"); browser.post("/login").send().await.assert_status(200); browser.get("/me").send().await.assert_text("user 42"); let sid = browser.cookies().into_iter().find(|c| c.name == "zapline.sid").unwrap(); assert!(sid.secure && sid.http_only && sid.host_only); // Scripts on the page can't read the session id. assert_eq!(browser.document_cookie("https://app.example.com/"), ""); } }
The store enforces Secure like a browser: Secure session cookies work over https base URLs and on the default http://localhost, but not on another plain-http host.
Flash messages
zapline::session::Flash keeps one-time messages in the session for the next page: flash.success("Saved") (or .error, .info, .warning), then flash.redirect("/posts"). The page reads them once: in templates as {% for m in flash %} (Views: Flash messages), in handlers with flash.take(), which returns Vec<FlashMessage> ({level, message}). htmx requests answered in place receive them as an HX-Trigger: {"flash": ...} event instead.
CSRF
There are two ways to stop cross-site request forgery. Most apps want the first.
SameOrigin: no tokens
SameOrigin (no feature needed) refuses unsafe requests (anything but GET, HEAD, OPTIONS and TRACE) that a browser sent on behalf of another site, with 403 cross_origin. It reads the headers browsers add themselves: Sec-Fetch-Site (every current browser), otherwise Origin, which must match the request's host. Requests with neither are not from a browser (curl, API clients, tests) and pass. Nothing changes in your forms, htmx attributes or fetch calls. zapline g auth installs it.
#![allow(unused)] fn main() { use zapline::middleware::SameOrigin; use zapline::prelude::*; fn app() -> App { App::new() .wrap(SameOrigin::new() .allow(["https://app.example.com"]) // a front end on another origin, with cookies .exempt(["/webhooks/"])) // callbacks other services send .post("/transfer", || async { "sent" }) } }
It also stops sibling subdomains (Sec-Fetch-Site: same-site), which SameSite=Lax cookies let through. Keep GET handlers free of side effects: they always pass. Browsers that send neither header (versions from before 2020) are left to SameSite cookies; if you must support them fully, use Csrf.
Csrf: signed tokens
Csrf is signed double-submit-cookie protection for browser apps that authenticate with cookies. On the first response to a client it sets a random token cookie (__Host-zapline.csrf, deliberately readable by JavaScript). Requests with an unsafe method (anything but GET, HEAD, OPTIONS and TRACE) must send the same token back in the x-csrf-token header or, for application/x-www-form-urlencoded bodies, in the csrf_token form field. Otherwise they get 403 with the code csrf_failed. The comparison is constant-time.
Register a cookie key with App::cookie_key (or give one to .key(..)); it is strongly recommended. Tokens are then signed: 32 random bytes and a 16-byte session binding tag (below), followed by their HMAC-SHA256 under the key, base64url-encoded (107 characters), and only tokens this server minted are accepted. Without a key (and without SessionLayer, whose key is used otherwise), tokens are plain random values (43 characters) and anything well-formed is accepted.
Signing matters because SameSite doesn't stop a sibling subdomain. A page on evil.example.com (or a compromised blog.example.com) is same-site with app.example.com, so it can set zapline.csrf=<chosen>; Domain=example.com; Path=/transfer. Browsers send that cookie first, and the attacker submits the same chosen value. Three things stop this:
- Signed tokens. A planted value of the attacker's choosing doesn't verify, so it is ignored.
- The
__Host-prefix. Browsers refuse a__Host-cookie unless it isSecure, hasPath=/and noDomain, so other hosts can't set it at all. The default name has it whenever the cookie isSecure(the default). With.secure(false), for development over plain HTTP, the default name iszapline.csrf, because browsers would drop a__Host-cookie withoutSecure. - Several token cookies must agree. If a request carries two different valid tokens, unsafe requests are rejected whichever one is submitted. This stops an attacker who plants a token the server minted for them next to the victim's (and the
__Host-prefix stops the planting itself). - Tokens are bound to the session when
SessionLayeris installed, so a token the server minted for the attacker fails with the victim's session even when it is the only one the request carries.
Install it as global middleware and render the token with the CsrfToken extractor:
#![allow(unused)] fn main() { use zapline::auth::{Csrf, CsrfToken}; use zapline::cookies::Key; use zapline::prelude::*; async fn form(token: CsrfToken) -> Html<String> { Html(format!( r#"<form method="post" action="/transfer">{}<button>Send</button></form>"#, token.hidden_input() )) } async fn transfer(Form(fields): Form<std::collections::HashMap<String, String>>) -> String { format!("{} fields received", fields.len()) } fn app() -> App { App::new() .cookie_key(Key::generate()) // load a stable key in production .wrap(Csrf::new().exempt("/webhooks/*")) .get("/form", form) .post("/transfer", transfer) } }
Bound to the session
With SessionLayer installed, Csrf binds every token to the session. The tag inside the token is an HMAC of the session id the client presents (or of "no session" for anonymous visitors), so:
- A token minted for one session fails for every other one. An attacker who gets a valid token for their own session and plants it in the victim's browser (a sibling subdomain can set a cookie without the
__Host-prefix, or push the victim's cookie out of the jar) gets403 csrf_failed. The tag reveals nothing about the session id. - Tokens rotate with the session id. When a request starts a session, calls
regenerate()(do it on login) ordestroy()(logout), its response carries a new token cookie bound to the new id, and the old token stops working. A session that is only saved or extended keeps its token. - No store access. Binding reads the signed session cookie only, so routes that never extract
Sessionstill don't touch the session store.
Both wrap orders work. With SessionLayer first, Csrf binds the token itself; with Csrf first (or SessionLayer on a route group), the session layer binds it when it runs, and handlers inside it see the bound token through CsrfToken. Routes outside a session group use unbound tokens, which a session group replaces with bound ones on its first safe request (a GET of its form), so a form posted to the group must be rendered by it. Without a Csrf key or an app key, the session's key signs the tokens.
#![allow(unused)] fn main() { use zapline::auth::{Csrf, CsrfToken}; use zapline::cookies::Key; use zapline::prelude::*; use zapline::session::{MemoryStore, Session, SessionLayer}; async fn login(session: Session) -> Result<Redirect> { session.regenerate(); // new id: the CSRF token rotates too session.insert("user", "ada")?; Ok(Redirect::to("/")) // the next page renders the new token } fn app() -> App { App::new() .cookie_key(Key::generate()) .wrap(SessionLayer::new(MemoryStore::new())) .wrap(Csrf::new()) .get("/", |t: CsrfToken| async move { Html(format!("<form method=post action=/login>{}</form>", t.hidden_input())) }) .post("/login", login) } }
JavaScript clients read the cookie (or get the token from the page) and send it as a header:
fetch("/transfer", {
method: "POST",
headers: { "x-csrf-token": token, "content-type": "application/json" },
body: JSON.stringify({ amount: 5 }),
});
Csrf method | Default | Effect |
|---|---|---|
.cookie_name(name) | __Host-zapline.csrf (zapline.csrf with .secure(false)) | Token cookie name. The cookie has Path=/ and no Domain; a __Host- name also needs Secure. |
.header_name(name) | x-csrf-token | Request header carrying the token |
.form_field(name) | csrf_token | URL-encoded form field carrying the token |
.exempt(path) | none | Skips the check for an exact path, or for a prefix when it ends in * ("/webhooks/*") |
.secure(bool) | true | Secure attribute of the token cookie. Disable it only for local development; the default name then drops its __Host- prefix. |
.same_site(SameSite) | Lax | SameSite attribute of the token cookie |
.key(key) | the app's key | Sign tokens with this key instead of App::cookie_key. With neither, tokens are unsigned. |
CsrfToken offers as_str(), field_name(), hidden_input() (an HTML-escaped <input type="hidden">) and Display. Without the middleware, extracting it fails with 500 csrf_layer_missing.
Things to know:
- Forms must be URL-encoded to use the field.
multipart/form-databodies are never inspected; send the token in the header instead (for example, in afetchcall). - APIs that authenticate with an
Authorizationheader don't need CSRF. Browsers don't attach that header on their own. ApplyCsrfonly to the cookie-authenticated part of the app, for instance with.exempt("/api/*"), or useRoutes::wrapon the group. - Exempt paths still receive a token cookie, and they are matched against the request path (before routing).
- Changing the key (or adding one) invalidates existing tokens. Clients get a fresh token on their next safe request, and forms rendered before the change fail once with
403. - With sessions, the token rotates when the session id changes (login, logout). Answer a login form with a redirect (Post/Redirect/Get) so the next page renders the new token; JavaScript that reads the cookie before each request always has the current one.
- Defense in depth: keep the session cookie
SameSite=Lax(the default) orStrict, in addition to the token.
Hardening middleware
A handful of built-in middleware turn a working service into one you can put on the internet. Each is one line:
#![allow(unused)] fn main() { use std::time::Duration; use zapline::middleware::{CatchPanic, RateLimit, SecurityHeaders, Timeout}; use zapline::prelude::*; fn app() -> App { App::new() .wrap(CatchPanic::new()) .wrap(SecurityHeaders::default()) .wrap(Timeout::new(Duration::from_secs(30))) .wrap_route(RateLimit::per_ip(300, Duration::from_secs(60))) .get("/", || async { "hello" }) } }
| Middleware | Protects against | Feature |
|---|---|---|
SecurityHeaders | Clickjacking, MIME sniffing, downgrade attacks, over-broad browser features | |
RateLimit | Brute force, scraping, noisy clients | |
Compression | Bandwidth cost (not an attack, but it is why you want it) | compression |
ConcurrencyLimit | Overload: sheds load instead of collapsing | |
Timeout | Handlers (or dependencies) that hang | |
CatchPanic | A bug in one handler dropping connections | |
NormalizePath | /users vs /users/ vs //users inconsistencies | |
SetRequestId | Unattributable errors | |
Cors | Cross-origin access you didn't intend | |
SameOrigin | Cross-site request forgery against cookie sessions | |
DefaultHeaders | Adds fixed headers (Server, Cache-Control, ...) to every response. Headers the handler set win. |
All of them are in zapline::middleware. Remember how middleware is arranged (see Middleware): App::wrap runs before routing for every request including 404s, and the first wrap is the outermost layer. App::wrap_route and Routes::wrap run after routing and can see the matched route and its metadata. The last section puts it all together.
SecurityHeaders
Adds browser hardening headers to every response. Headers the handler already set are never overwritten, and the values are built once, at construction.
#![allow(unused)] fn main() { use std::time::Duration; use zapline::middleware::SecurityHeaders; use zapline::prelude::*; fn app() -> App { App::new() .wrap(SecurityHeaders::default()) // sensible defaults } fn https_only_app() -> App { App::new().wrap(SecurityHeaders::strict()) // + HSTS, CSP, CORP } fn custom() -> SecurityHeaders { SecurityHeaders::default() .hsts(Duration::from_secs(31_536_000), true, false) .csp("default-src 'self'; img-src 'self' https://cdn.example.com") .frame_options("SAMEORIGIN") .remove("x-dns-prefetch-control") } }
SecurityHeaders::default() sets:
| Header | Value |
|---|---|
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Referrer-Policy | strict-origin-when-cross-origin |
Cross-Origin-Opener-Policy | same-origin |
X-DNS-Prefetch-Control | off |
Permissions-Policy | camera, microphone, geolocation, payment, usb, ... all disabled |
It sets no Content-Security-Policy, so pages that load assets from a CDN (including the interactive API docs) keep working.
SecurityHeaders::strict() adds Strict-Transport-Security: max-age=63072000; includeSubDomains; preload, Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data:; upgrade-insecure-requests and Cross-Origin-Resource-Policy: same-origin. Think before you use it:
- HSTS makes browsers refuse plain HTTP for your whole domain (and, with
includeSubDomains, its subdomains) for two years. Use it only when everything is served over HTTPS. Browsers ignore it over plain HTTP. - The strict CSP blocks the Swagger UI, Scalar and Redoc pages, which load scripts from a CDN and use inline scripts. Either serve docs only in non-production, or relax the policy for your app:
SecurityHeaders::strict().csp("..."), or.remove("content-security-policy").
| Method | Effect |
|---|---|
.hsts(max_age, include_subdomains, preload) | Strict-Transport-Security |
.csp(policy) / .csp_report_only(policy) | Content-Security-Policy / ...-Report-Only, to trial a policy |
.frame_options(value) | X-Frame-Options (DENY or SAMEORIGIN) |
.referrer_policy(value) | Referrer-Policy |
.permissions_policy(value) | Permissions-Policy, for example camera=(), geolocation=(self) |
.header(name, value) | Sets or replaces any header. Panics on an invalid name or value. |
.remove(name) | Stops sending a header (case-insensitive) |
RateLimit
Limits requests per client with GCRA, a smooth token bucket: per_ip(100, 1 minute) allows a burst of 100, then one request every 0.6 seconds. State lives in a sharded, bounded in-memory store (default 100,000 clients, about 2 MB). Expired entries are swept lazily, so there is no background task. Clones share state.
Limited requests get 429 Too Many Requests with the code rate_limited and a Retry-After header:
HTTP/1.1 429 Too Many Requests
content-type: application/json
retry-after: 30
ratelimit-limit: 2
ratelimit-remaining: 0
ratelimit-reset: 60
{"error":"too many requests","code":"rate_limited"}
Every response from a limited route carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. With several limiters stacked, the tightest one is reported. Turn the headers off with .headers(false); a 429 always carries Retry-After.
Who is the client?
| Constructor | Counts requests per |
|---|---|
RateLimit::per_ip(limit, period) | Client IP address. IPv6 addresses are grouped by /64 (see .ipv6_prefix(bits)), because one host usually controls a whole /64. |
RateLimit::global(limit, period) | Everyone together (a lock-free single bucket) |
RateLimit::by_header(name, limit, period) | Value of a header such as an API key. Requests without the header are limited per IP. |
RateLimit::by_key(limit, period, |req| Option<String>) | Whatever your closure returns. None skips limiting for that request. |
RateLimit::keyed(limit, period, key) | The same, with any RateLimitKey implementation |
A header-based key is only as trustworthy as the header. If clients can send any string as x-api-key, they can dodge the limit by sending a new one on every request. Key on identities you have verified (a user ID from your authenticator, a validated API key) and keep a per-IP limiter as well.
#![allow(unused)] fn main() { use std::time::Duration; use zapline::middleware::RateLimit; use zapline::prelude::*; const MINUTE: Duration = Duration::from_secs(60); fn limiters() -> (RateLimit, RateLimit) { let per_ip = RateLimit::per_ip(100, MINUTE) .burst(20) // at most 20 back-to-back .trust_forwarded_for(["10.0.0.0/8"]) .exempt(|req: &Request| req.path() == "/healthz"); let per_tenant = RateLimit::by_key(1000, MINUTE, |req: &Request| { req.headers().get("x-tenant")?.to_str().ok().map(str::to_owned) }); (per_ip, per_tenant) } }
| Method | Effect |
|---|---|
.burst(n) | Allow up to n requests back to back (default: limit) |
.trust_forwarded_for(proxies) | Take the client IP from X-Forwarded-For, but only when the connection comes from one of these proxies (see below) |
.trusted_proxies(TrustedProxies) | Full control: the header (X-Forwarded-For, RFC 7239 Forwarded, or a single-address header such as X-Real-IP) |
.ipv6_prefix(bits) | Group IPv6 clients by this prefix length (default 64) |
.exempt(|req| bool) | Never limit requests for which this returns true |
.headers(bool) | Send the RateLimit-* headers (default true) |
.max_keys(n) | Maximum number of tracked clients (default 100,000). When full, the bucket closest to expiry is evicted. |
Behind a proxy: trusted proxies
Behind a load balancer or reverse proxy, the TCP peer is always the proxy. If you do nothing, every client shares one bucket (the proxy's IP) and one busy client locks everyone out. The fix looks easy: read X-Forwarded-For. But that header is just text the client can send too, so trusting it blindly lets any client pick its own identity and dodge the limit, or frame someone else.
That is why forwarding headers are ignored unless the peer is a trusted proxy that you listed. The header is then read right to left, skipping trusted proxies, and the first untrusted address is the client. Addresses to the left of that (the part a client could have written) are never reached unless every hop is trusted.
#![allow(unused)] fn main() { use std::time::Duration; use zapline::middleware::rate_limit::{ClientIpHeader, TrustedProxies}; use zapline::middleware::RateLimit; fn limiter() -> RateLimit { let proxies = TrustedProxies::new(["10.0.0.0/8", "127.0.0.1"]).header(ClientIpHeader::Forwarded); RateLimit::per_ip(100, Duration::from_secs(60)).trusted_proxies(proxies) } }
List only networks you control, the exact addresses of your ingress. limiter.client_ip(&req) shows the address a limiter derives for a request.
TrustedProxies::from_env() reads the list from TRUSTED_PROXIES (comma-separated), so it can differ between environments; zapline g auth uses it, and zapline g deploy fly and render set it to the private networks those hosts' proxies connect from. Trust a whole private range only when nothing else can reach the app directly.
Per-route limits
A limiter installed with App::wrap_route or Routes::wrap runs after routing, so it can see the route's metadata. Its own limit is the default, and annotated routes get their own limit and buckets:
#![allow(unused)] fn main() { use std::time::Duration; use zapline::middleware::{RateLimit, RateLimitPolicy}; use zapline::prelude::*; const MINUTE: Duration = Duration::from_secs(60); fn app() -> App { App::new() .post("/login", || async { "welcome" }) .rate_limit(5, MINUTE) // 5 attempts per minute per client .post("/password-reset", || async { "sent" }) .meta(RateLimitPolicy::new(3, MINUTE).burst(1).scope("auth")) // shared bucket "auth" .get("/healthz", || async { "ok" }) .meta(RateLimitPolicy::exempt()) // never limited .get("/items", || async { "items" }) .wrap_route(RateLimit::per_ip(300, MINUTE)) // everything else } }
.rate_limit(limit, period) is on both App and Routes and applies to the most recently registered route. Policies that share a .scope(name) share buckets, for example to cap login and password reset together. A limiter installed with plain App::wrap runs before routing and cannot see policies: it applies its own limit to everything, 404s included.
Compression
Needs the Cargo feature compression. Compresses response bodies with the best encoding the client accepts: brotli, zstd, gzip or deflate, negotiated from Accept-Encoding q-values (ties prefer br > zstd > gzip > deflate).
#![allow(unused)] fn main() { use zapline::middleware::compression::is_compressible; use zapline::middleware::{Compression, CompressionLevel}; use zapline::prelude::*; fn app() -> App { App::new() .wrap(Compression::new().zstd(false).min_size(1024)) .get("/", || async { "hello ".repeat(1000) }) } fn tuned() -> Compression { Compression::new() .quality(CompressionLevel::Fastest) .content_types(|ct| is_compressible(ct) || ct == "text/event-stream") } }
| Method | Effect |
|---|---|
.gzip(bool), .br(bool), .zstd(bool), .deflate(bool) | Enable or disable an encoding (all on by default) |
.quality(CompressionLevel) | Fastest, Default (brotli 4, zstd 3, gzip/deflate 6), Best or Precise(n) |
.min_size(bytes) | Don't compress bodies known to be smaller (default 860) |
.content_types(|ct| bool) | Which media types to compress (a lowercase type without parameters). Default: is_compressible. |
What it does and doesn't touch:
- It skips
HEAD, 1xx, 204, 206 and 304 responses, responses that already have aContent-Encodingor aContent-Range, responses withCache-Control: no-transform, bodies belowmin_sizeand content types that don't compress. By default it compresses text, JSON, JavaScript, XML, SVG, WebAssembly, YAML, TOML, forms and uncompressed fonts, and skips images, audio, video, archives andwoff/woff2. text/event-streamis skipped by default, because buffering encoders delay events. Opt in withcontent_typesif you need it (the encoder flushes whenever the source pauses).- It sets
Content-Encoding, addsVary: accept-encoding, dropsContent-LengthandAccept-Ranges, and weakens a strongETagtoW/"...". - Buffered bodies up to 1 MiB are compressed in one go and keep their length. Larger and streaming bodies are compressed as they stream.
- Static files can be served pre-compressed instead; see Static files & uploads.
ConcurrencyLimit
Caps the number of requests processed at once. Beyond the cap, requests are shed with 503 Service Unavailable, Retry-After and the code overloaded. That keeps latency bounded for the requests you accept, instead of letting everything slow to a crawl:
#![allow(unused)] fn main() { use std::time::Duration; use zapline::middleware::ConcurrencyLimit; use zapline::prelude::*; fn app() -> App { // At most 512 requests in flight; wait up to 50 ms for a slot, then answer 503. App::new().wrap(ConcurrencyLimit::new(512).queue_timeout(Duration::from_millis(50))) } fn exports() -> Routes { // An expensive group gets its own budget. Routes::new() .get("/export", || async { "big report" }) .wrap(ConcurrencyLimit::new(4).retry_after(5)) } }
By default excess requests are shed immediately. .queue_timeout(duration) lets them wait briefly for a slot, and .retry_after(secs) sets the Retry-After value (default 1). max() and available() report the configured maximum and the free slots. A slot is held until the response head is produced, so streaming bodies, SSE and WebSockets are not counted for their whole lifetime. Clones share the limit, as do all routes under one Routes::wrap. Panics if max is 0.
Timeout
Fails requests that take longer than a duration with 503 {"error":"request timed out","code":"timeout"}:
#![allow(unused)] fn main() { use std::time::Duration; use zapline::middleware::Timeout; use zapline::prelude::*; fn app() -> App { App::new().wrap(Timeout::new(Duration::from_secs(30))) } }
It bounds the time until the response is produced, and it drops the handler's future when the deadline passes (so cancel-safety matters for handlers that write to a database). Bodies that stream after the head, SSE and WebSockets are not cut off. To give one route a different budget, wrap that route's group: Routes::new().get("/export", export).wrap(Timeout::new(Duration::from_secs(300))). Both limits apply when they are nested, and the shorter one wins. Slow clients (slowloris) are a separate protection, ServerConfig::header_read_timeout (see Server).
CatchPanic
Turns a panic in a handler or in any middleware after it into 500 {"error":"internal server error","code":"panic"} instead of a dropped connection. The panic message is logged, never sent to the client.
#![allow(unused)] fn main() { use zapline::middleware::CatchPanic; use zapline::prelude::*; fn app() -> App { App::new() .wrap(CatchPanic::new().handler(|message: String| { eprintln!("handler panicked: {message}"); (StatusCode::INTERNAL_SERVER_ERROR, "something broke").into_response() })) .get("/boom", || async { if true { panic!("oops") } "unreachable" }) } }
handler(..) builds the response yourself from the panic message (a panic inside your handler is caught, too). Install it first (outermost) so it covers every layer after it.
It cannot work with panic = "abort". A binary built with [profile.release] panic = "abort" (which the benchmark profile and the deployment example use) terminates the whole process on the first panic, so there is nothing to catch. Pick one deliberately:
panic = "unwind"(the default) plusCatchPanic: a bug in one handler costs that one request. The process, and every other in-flight request, survives.panic = "abort": a slightly smaller and faster binary, but any panic takes down every in-flight request on that instance and relies on your orchestrator to restart it.
For a public API with many concurrent users, the first is usually the better trade.
NormalizePath
Makes /users/, /users and //users hit the same route. Route patterns never end in /, and trailing slashes are significant, so without it GET /users/ is a 404. It must be installed with App::wrap: it rewrites the URI before routing.
#![allow(unused)] fn main() { use zapline::middleware::NormalizePath; use zapline::prelude::*; fn app() -> App { App::new() .wrap(NormalizePath::trim_trailing_slash().merge_slashes(true)) .get("/users", || async { "users" }) // GET /users/ and GET //users// both reach it } fn canonical() -> NormalizePath { // Send clients to the canonical URL instead of rewriting silently. NormalizePath::trim_trailing_slash().redirect(StatusCode::PERMANENT_REDIRECT) } }
| Method | Effect |
|---|---|
NormalizePath::trim_trailing_slash() | /users/ becomes /users (the default). The root / is untouched. ServeDir then serves a directory's index at /dir instead of redirecting to /dir/, which would loop (see Static files). |
NormalizePath::append_trailing_slash() | /users becomes /users/. Paths whose last segment contains a . (like /app.js) are left alone. Mostly useful with redirect or with catch-all handlers such as static files. |
.merge_slashes(true) | /a//b becomes /a/b |
.redirect(status) | Answer with a redirect to the normalized URL, query string preserved. Panics if status is not 3xx. |
Redirect targets are always rewritten to a single leading slash, so a request for //evil.example can never become an open redirect.
SetRequestId and RequestId
SetRequestId tags every request with a unique ID, so a log line, a trace and a client's bug report can be tied together. It reuses a valid incoming x-request-id (so IDs survive a hop through a proxy or another service) or generates a time-ordered UUIDv7. The ID is stored in the request, written to the request headers for downstream code, and echoed in the response header (unless the handler set it).
#![allow(unused)] fn main() { use zapline::middleware::{RequestId, SetRequestId}; use zapline::prelude::*; fn app() -> App { App::new() .wrap(SetRequestId::new()) .get("/", |id: RequestId| async move { format!("request {id}") }) } fn strict() -> SetRequestId { SetRequestId::new() .header("x-correlation-id") .trust_incoming(false) // always generate our own } }
| Method | Effect |
|---|---|
.header(name) | Read and write another header (default x-request-id) |
.trust_incoming(bool) | Reuse a valid incoming ID (default true). Turn it off when clients are untrusted and IDs must be server-generated. |
.generator(|| String) | Generate IDs yourself. Values that fail validation are replaced by a default ID. |
Valid IDs have at most 128 characters from [A-Za-z0-9._:-], which keeps log injection out. RequestId is also an extractor (id.as_str(), Display); without the middleware it falls back to a valid x-request-id header or a fresh ID that stays stable for the rest of the request. In middleware, req.request_id() returns it. Install it globally so 404s get an ID too. Trace and Logger print it.
Cors
Cross-Origin Resource Sharing. Install it globally, so preflight (OPTIONS) requests are answered before routing:
#![allow(unused)] fn main() { use std::time::Duration; use zapline::middleware::Cors; use zapline::prelude::*; fn app() -> App { let cors = Cors::origins(["https://app.example.com"]) .allow_origin_fn(|origin: &HeaderValue| origin.as_bytes().ends_with(b".preview.example.com")) .allow_credentials() .expose_headers("x-request-id, ratelimit-remaining") .max_age(Duration::from_secs(600)); App::new().wrap(cors) } }
| Constructor / method | Effect |
|---|---|
Cors::permissive() | Any origin, common methods, any request header. For development and public read-only APIs. |
Cors::origins([..]) | Only the listed origins. Panics on an invalid value. |
.allow_origin_fn(|origin| bool) | Also allow origins for which the function returns true (for example preview deployments) |
.allow_methods("GET, POST"), .allow_headers("content-type, authorization") | The preflight answers (* allows any) |
.allow_credentials() | Allow cookies and Authorization headers; the matched origin is echoed instead of *, as the spec requires. Panics on Cors::permissive() (any origin): set Cors::origins([..]) or .allow_origin_fn(..) first. |
.expose_headers("..") | Response headers that scripts may read (x-request-id, ratelimit-remaining, ...) |
.max_age(duration) | How long browsers cache a preflight (default 24 hours) |
Things to know:
- Credentials require an origin allow-list.
Cors::permissive().allow_credentials()panics at startup, because echoing any origin with credentials would let any website call your API with your users' cookies. Use an explicitCors::origins([..])list. - CORS is enforced by browsers. The server still runs the request; only the response headers decide whether a script may read the result. It is not authentication.
- Responses that depend on the
OrigingetVary: origin. - Put
Corsoutside rate limiting, concurrency limits and authentication, so a429,503or401still carries CORS headers. Otherwise the browser reports an opaque CORS error instead of your real error. - Native apps and
curlsend noOriginand are unaffected.
A production stack
Global middleware runs outermost-first in the order added. This order works well, and each position is deliberate:
#![allow(unused)] fn main() { use std::time::Duration; use zapline::middleware::{ CatchPanic, Compression, ConcurrencyLimit, Cors, NormalizePath, RateLimit, SecurityHeaders, SetRequestId, Timeout, }; use zapline::prelude::*; use zapline::telemetry::Trace; const MINUTE: Duration = Duration::from_secs(60); async fn items() -> &'static str { "items" } async fn login() -> &'static str { "welcome" } fn app() -> App { let api = Routes::new() .get("/items", items) .post("/login", login) .rate_limit(5, MINUTE); // stricter, with its own bucket App::new() // 1. Identity and visibility: first, so everything below is attributed. .wrap(SetRequestId::new()) .plugin(Trace::new().skip_paths(["/health/live", "/metrics"])) // 2. Never let a panic escape; inside Trace so it is logged as a 500. .wrap(CatchPanic::new()) // 3. Canonical paths before anything keyed on the path. .wrap(NormalizePath::trim_trailing_slash()) // 4. Headers on every response, including the rejections below. .wrap(SecurityHeaders::default()) .wrap(Cors::origins(["https://app.example.com"]).expose_headers("x-request-id")) // 5. Protect the app: shed load, then bound handler time. .wrap(ConcurrencyLimit::new(1024).queue_timeout(Duration::from_millis(100))) .wrap(Timeout::new(Duration::from_secs(30))) // 6. Compress what the handlers produce, closest to the handlers. .wrap(Compression::new()) .nest("/api", api) .get("/healthz", || async { "ok" }) // Route middleware runs after routing, so per-route limits apply. .wrap_route( RateLimit::per_ip(300, MINUTE) .trust_forwarded_for(["10.0.0.0/8"]) .exempt(|req: &Request| req.path() == "/healthz"), ) } }
Why this order:
SetRequestId, thenTrace, outermost. Every later layer, including the ones that reject a request, shows up in the logs with an ID.Tracerecords the ID, soSetRequestIdgoes first.CatchPanicinsideTrace.Tracethen sees the500the panic became and logs it atERROR. Panics in the outer layers are not expected.NormalizePathbefore everything keyed on paths (CSRF exemptions,Trace::skip_paths,RateLimit::exempt), and necessarily before routing.SecurityHeadersandCorsoutside the limiters. Rejections (429, 503, 401) are responses too. They need the same headers, or the browser can't show the real error.ConcurrencyLimitoutsideTimeout. A timed-out request frees its slot. Shed load as early as possible.Compressioninnermost of the global layers. It compresses what handlers produce, and the small error bodies from the layers above skip the work. The CPU it spends is covered by the limits and the timeout.RateLimitaswrap_route. Only route middleware can see per-route policies like.rate_limit(5, MINUTE). The cost is that unmatched requests (404s) aren't rate-limited; they allocate nothing and are cheap, andConcurrencyLimitstill covers floods. UseApp::wrap(RateLimit::..)instead if you need to limit 404s, and accept that policies are ignored there.
Authentication and authorization go closer to the handlers still, on route groups. See Authentication & authorization for why role checks must run after routing. Add Csrf globally if your app serves cookie-authenticated forms, with a cookie key registered (App::cookie_key) so its tokens are signed. With SessionLayer installed, its tokens are also bound to the session and rotate on login and logout. Test the whole stack against real cross-site requests with the test client's from_origin (Testing). And pair this stack with Metrics and Health from Observability, remembering that wrap_route middleware also covers their routes (exempt /healthz and friends, as above).
WebSockets
Take a WebSocketUpgrade in a GET handler and give it an async callback:
#![allow(unused)] fn main() { use zapline::prelude::*; use zapline::ws::{WebSocket, WebSocketUpgrade}; async fn echo(ws: WebSocketUpgrade) -> Response { ws.on_upgrade(|mut socket: WebSocket| async move { while let Some(Ok(msg)) = socket.recv().await { if (msg.is_text() || msg.is_binary()) && socket.send(msg).await.is_err() { break; } } }) } fn app() -> App { App::new().get("/ws", echo) } }
Needs the Cargo feature ws (it pulls in tokio-tungstenite). The handler returns 101 Switching Protocols; the callback then runs in its own task with the connected WebSocket, long after the handler has returned. The callback may return () or Result<(), E: Display>; an Err is logged.
The handshake: WebSocketUpgrade
As an extractor, WebSocketUpgrade validates the handshake and rejects bad requests before your handler runs:
| Problem | Status | Code |
|---|---|---|
Not a GET request | 400 | ws_invalid_method |
Missing Connection: upgrade or Upgrade: websocket | 426 with Upgrade: websocket | ws_upgrade_required |
Sec-WebSocket-Version is not 13 | 426 with Sec-WebSocket-Version: 13 | ws_unsupported_version |
Missing or malformed Sec-WebSocket-Key | 400 | ws_invalid_key |
Because it is an ordinary extractor, it composes with the others: take State<T>, Path<T>, Query<T>, a session or CurrentUser<U> first, and reject unauthorized clients with a plain Error before the upgrade happens.
Subprotocols
.protocols([..]) lists the subprotocols you support in order of preference. The first one the client also offered is selected and echoed back; if none match, no protocol is selected and the client decides whether to proceed. Inside the callback, socket.protocol() returns the negotiated name. requested_protocols() and selected_protocol() are available on the upgrade, too.
#![allow(unused)] fn main() { use zapline::prelude::*; use zapline::ws::WebSocketUpgrade; async fn chat(ws: WebSocketUpgrade) -> Response { ws.protocols(["chat.v2", "chat.v1"]) .max_message_size(64 * 1024) .on_upgrade(|socket| async move { let _version = socket.protocol(); // Some("chat.v2"), Some("chat.v1") or None }) } }
Limits and buffers
An incoming message larger than the limit closes the connection with an error. Lower the defaults to what your protocol needs: the defaults suit file transfer, not chat.
| Method | Default | Effect |
|---|---|---|
.max_message_size(bytes) | 16 MiB (DEFAULT_MAX_MESSAGE_SIZE) | Largest incoming message, after fragments are reassembled |
.max_frame_size(bytes) | 16 MiB (DEFAULT_MAX_FRAME_SIZE) | Largest single incoming frame |
.read_buffer_size(bytes) | 8 KiB | Per-connection read buffer; it grows for larger messages. Raise it for sockets that mostly receive large messages. |
.write_buffer_size(bytes) | 128 KiB | Outgoing data is buffered until this many bytes are queued. 0 writes eagerly. Every send flushes regardless. |
.max_write_buffer_size(bytes) | unlimited | Cap on the outgoing buffer when writes are failing. Kept above the write buffer size. |
The WebSocket
| Method | Effect |
|---|---|
recv().await | The next Message. None once the connection is closed (after the close handshake, or if the peer went away). |
send(msg).await | Sends and flushes. Accepts a Message, String, &str, Bytes, Vec<u8> or &[u8]. |
close().await | Starts the close handshake with code 1000. Keep calling recv until it returns None, or just drop the socket. |
close_with(code, reason).await | Closes with a code from zapline::ws::close_code and a reason (at most 123 bytes are sent) |
protocol() | The negotiated subprotocol |
send_json(&value).await | Serializes with serde_json and sends a text message (needs the json feature) |
recv_json::<T>().await | The next text or binary message parsed as JSON. Skips pings and pongs. None on close. |
WebSocket implements Stream and Sink, so futures_util::StreamExt::split() gives independent reader and writer halves. Pings are answered with pongs automatically while you are reading, and they are still delivered to you as Message::Ping. Read errors are zapline::Errors with stable codes: ws_closed (410), ws_message_too_large (413), ws_protocol_error (400), ws_io_error (500), ws_invalid_json (422, from parse_json/recv_json).
Messages
zapline::ws::Message is zapline's own type, so your code doesn't change when tungstenite does:
| Variant | Constructor | Helpers |
|---|---|---|
Text(String) | Message::text(s) | is_text(), as_text(), into_text() |
Binary(Bytes) | Message::binary(b) | is_binary() |
Ping(Bytes) / Pong(Bytes) | Message::ping(b) / Message::pong(b) (payload at most 125 bytes) | is_ping(), is_pong() |
Close(Option<CloseFrame>) | Message::close(code, reason) | is_close() |
| any | Message::json(&value)? (text) | parse_json::<T>(), as_bytes(), into_bytes() |
Typed JSON messages are one line each way:
#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; use zapline::prelude::*; use zapline::ws::{WebSocket, WebSocketUpgrade}; #[derive(Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum Incoming { Subscribe { topic: String }, Ping, } #[derive(Serialize)] struct Reply { ok: bool, } async fn api(ws: WebSocketUpgrade) -> Response { ws.on_upgrade(|mut socket: WebSocket| async move { while let Some(Ok(msg)) = socket.recv_json::<Incoming>().await { let ok = matches!(msg, Incoming::Subscribe { .. } | Incoming::Ping); if socket.send_json(&Reply { ok }).await.is_err() { break; } } }) } }
Broadcast chat
The usual shape of a realtime feature: every connection forwards a shared broadcast channel to its client while reading the client's messages. Split the socket, run both directions as tasks, and stop one when the other ends:
#![allow(unused)] fn main() { use futures_util::{SinkExt, StreamExt}; use tokio::sync::broadcast::{self, error::RecvError}; use zapline::prelude::*; use zapline::ws::{Message, WebSocket, WebSocketUpgrade}; #[derive(Clone)] struct Room(broadcast::Sender<String>); async fn chat(ws: WebSocketUpgrade, State(room): State<Room>) -> Response { ws.protocols(["chat"]) .max_message_size(4 * 1024) .on_upgrade(move |socket| session(socket, room)) } async fn session(socket: WebSocket, room: Room) { let (mut sink, mut stream) = socket.split(); // Room -> this client. let mut rx = room.0.subscribe(); let mut outgoing = tokio::spawn(async move { loop { match rx.recv().await { Ok(text) => { if sink.send(Message::text(text)).await.is_err() { break; } } Err(RecvError::Lagged(_)) => continue, // slow client: skip ahead Err(RecvError::Closed) => break, } } }); // This client -> room. let tx = room.0.clone(); let mut incoming = tokio::spawn(async move { while let Some(Ok(msg)) = stream.next().await { if let Some(text) = msg.as_text() { let _ = tx.send(text.trim().to_owned()); } } }); // When either side finishes, stop the other. tokio::select! { _ = &mut outgoing => incoming.abort(), _ = &mut incoming => outgoing.abort(), } } fn app() -> App { let (tx, _) = broadcast::channel(256); App::new().state(Room(tx)).get("/ws", chat) } }
The browser side is the standard API:
const ws = new WebSocket(`ws://${location.host}/ws`, ["chat"]);
ws.onmessage = (e) => console.log(e.data);
ws.send("hello, room");
Production concerns
-
Authentication. Browsers cannot set an
Authorizationheader on aWebSocket. Authenticate with the session or cookie that the handshake request already carries (extractSessionorCurrentUser<U>in the handler), or with a short-lived token in the query string that you validate before upgrading. -
Check the
Origin. WebSocket handshakes are not subject to CORS, so a malicious page can open a socket to your server using the visitor's cookies (cross-site WebSocket hijacking). When you authenticate with cookies, reject unexpected origins:#![allow(unused)] fn main() { use zapline::prelude::*; use zapline::ws::WebSocketUpgrade; async fn secure_chat(headers: HeaderMap, ws: WebSocketUpgrade) -> Result<Response> { let origin = headers.get("origin").and_then(|v| v.to_str().ok()); if origin != Some("https://app.example.com") { return Err(Error::forbidden().with_code("bad_origin")); } Ok(ws.on_upgrade(|_socket| async {})) } } -
Middleware only sees the handshake.
Timeout,ConcurrencyLimitandRateLimitapply to the upgrade request. They don't bound the lifetime of the connection afterwards, and a connection doesn't hold a concurrency slot. The server does count it: an upgraded socket keeps itsmax_connectionsslot, or counts againstServerConfig::max_upgradedif you set it (upgrades beyond it get503, codews_too_many_connections). For per-user limits, keep a count in state, and usemax_message_size. See Server: WebSockets and the server. -
Dead peers. Idle proxies and NATs drop silent connections, and TCP won't tell you for a long time. Send a ping every 20 to 30 seconds with
socket.send(Message::ping(Bytes::new())), and treat a failed send as a closed connection. -
Reverse proxies must forward the upgrade headers. For nginx:
proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";, plus a longproxy_read_timeout. -
Shutdown. The drain waits for open sockets. Each one sends a
1001close frame (close_code::AWAY) the next time your callback reads or writes, so arecvloop ends by itself and a latersendfails withws_closed. Callbacks still running aftershutdown_timeoutare dropped. If your loop waits on something else, putsocket.recv()in the sameselect!so the close goes out at once. New upgrades during the drain get503(ws_shutting_down). -
HTTP/2. With the
http2feature, the same handler also serves WebSockets over HTTP/2 (RFC 8441), over TLS (ALPNh2) and h2c. The server advertisesSETTINGS_ENABLE_CONNECT_PROTOCOL; a client opens a socket with an extendedCONNECTand:protocol = websocket. zapline routes it like the HTTP/1.1GET(handlers and middleware seeGET), validatesSec-WebSocket-Version: 13, negotiates subprotocols, and answers200instead of101. Each socket is one HTTP/2 stream, so many sockets share one connection. WebSockets over HTTP/3 (RFC 9220) are not supported yet.
Testing
The in-process test client has no real connection to upgrade. There, on_upgrade still validates the handshake and returns the 101 response, so handshake behavior is testable, but the callback never runs:
#![allow(unused)] fn main() { use zapline::prelude::*; use zapline::ws::WebSocketUpgrade; fn app() -> App { App::new().get("/ws", |ws: WebSocketUpgrade| async move { ws.on_upgrade(|_socket| async {}) }) } #[tokio::test] async fn handshake_is_validated() { let client = app().test(); let res = client .get("/ws") .header("connection", "Upgrade") .header("upgrade", "websocket") .header("sec-websocket-version", "13") .header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==") .send() .await; assert_eq!(res.status(), 101); assert_eq!(client.get("/ws").send().await.status(), 426); // a plain GET is not an upgrade } }
To test the conversation itself, serve the app on an ephemeral port and connect with a real WebSocket client. Add one to your dev-dependencies, for example tokio-tungstenite = "0.30" with its connect feature:
#![allow(unused)] fn main() { use futures_util::{SinkExt, StreamExt}; use tokio_tungstenite::tungstenite::Message as ClientMessage; use zapline::prelude::*; use zapline::ws::{WebSocket, WebSocketUpgrade}; async fn echo(ws: WebSocketUpgrade) -> Response { ws.on_upgrade(|mut socket: WebSocket| async move { while let Some(Ok(msg)) = socket.recv().await { if (msg.is_text() || msg.is_binary()) && socket.send(msg).await.is_err() { break; } } }) } #[tokio::test] async fn echoes_over_a_real_socket() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(App::new().get("/ws", echo).serve(listener)); let (mut ws, response) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")).await.unwrap(); assert_eq!(response.status(), 101); ws.send(ClientMessage::text("hello")).await.unwrap(); let reply = ws.next().await.unwrap().unwrap(); assert_eq!(reply, ClientMessage::text("hello")); } }
Server-Sent Events & streaming
Return Sse with a stream of Events and each event reaches the client as soon as the stream yields it:
#![allow(unused)] fn main() { use std::time::Duration; use futures_util::stream::{self, Stream, StreamExt}; use zapline::prelude::*; use zapline::sse::{Event, KeepAlive, Sse}; async fn ticks() -> Sse<impl Stream<Item = Event>> { let events = stream::iter(1..=3).map(|n| Event::default().event("tick").data(n.to_string())); Sse::new(events).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))) } fn app() -> App { App::new().get("/ticks", ticks) } }
zapline::sse and zapline::stream are always compiled. Only Ndjson and the JSON helpers need the json feature, which is on by default. The examples use futures_util for stream combinators, so add futures-util = "0.3" to your Cargo.toml.
Server-Sent Events
An Sse response is Content-Type: text/event-stream with Cache-Control: no-cache and X-Accel-Buffering: no, so proxies such as nginx don't buffer the stream. The stream may yield plain Events or Result<Event, E>. An Err ends the response and closes the connection mid-stream, because once the headers are sent there is no way to report a status.
Event
Build events with a chain of methods. Fields are written in the order you call them.
| Method | Wire field | Notes |
|---|---|---|
.data(text) | data: | Multi-line text becomes one data: line per line |
.json_data(value)? | data: | Serializes compact JSON on one line. Returns Result<Event>. |
.event(name) | event: | The event type, dispatched to addEventListener(name) in the browser. Single line only. |
.id(id) | id: | Echoed by the browser in the Last-Event-ID header when it reconnects. No line breaks or NUL. |
.retry(duration) | retry: | The client's reconnection delay |
.comment(text) | :text | Ignored by clients |
Line breaks (\n, \r\n, \r) in data and comment become new lines of the same field, so user-supplied text can never inject extra fields. event and id must be single-line. A bad value doesn't panic: the event is marked invalid (is_valid() is false) and the stream ends with an error when it reaches the client. Event::to_bytes() gives the wire format.
KeepAlive
Proxies and load balancers close connections that stay silent. Sse::keep_alive(KeepAlive::new()) sends a comment frame whenever the stream has been idle for the interval (default 15 seconds, at least 1 ms). .text("ping") changes the comment text.
A live feed
A typical feed subscribes to a broadcast channel. Turn the receiver into a stream with stream::unfold:
#![allow(unused)] fn main() { use std::time::Duration; use futures_util::stream::{self, Stream}; use tokio::sync::broadcast::{self, error::RecvError}; use zapline::prelude::*; use zapline::sse::{Event, KeepAlive, Sse}; #[derive(Clone)] struct Bus(broadcast::Sender<String>); async fn notifications(State(bus): State<Bus>, headers: HeaderMap) -> Sse<impl Stream<Item = Event>> { // A reconnecting browser sends the id of the last event it saw. let last_seen = headers.get("last-event-id").and_then(|v| v.to_str().ok()).map(str::to_owned); if let Some(id) = last_seen { // replay missed events from your own store here let _ = id; } let events = stream::unfold(bus.0.subscribe(), |mut rx| async move { loop { match rx.recv().await { Ok(text) => return Some((Event::default().event("notice").data(text), rx)), Err(RecvError::Lagged(_)) => continue, // slow client: skip ahead Err(RecvError::Closed) => return None, // ends the response } } }); Sse::new(events).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))) } #[post("/notify")] async fn notify(State(bus): State<Bus>, body: String) -> StatusCode { let _ = bus.0.send(body); StatusCode::ACCEPTED } fn app() -> App { let (tx, _) = broadcast::channel(256); App::new().state(Bus(tx)).get("/notifications", notifications).mount(notify) } }
const es = new EventSource("/notifications");
es.addEventListener("notice", (e) => console.log(e.data));
When a client goes away, the response body, and with it your stream, is dropped. Release resources in Drop, or simply let the receiver drop. A stream with typed events and ids looks like this:
#![allow(unused)] fn main() { use std::time::Duration; use futures_util::stream::{self, Stream}; use serde::Serialize; use zapline::prelude::*; use zapline::sse::{Event, Sse}; #[derive(Serialize)] struct Tick { seq: u64, } async fn clock() -> Sse<impl Stream<Item = Result<Event>>> { let ticks = stream::unfold(0u64, |seq| async move { tokio::time::sleep(Duration::from_secs(1)).await; let event = Event::default().event("tick").id(seq.to_string()).json_data(Tick { seq }); Some((event, seq + 1)) }); Sse::new(ticks) } }
Things to know:
- Browsers allow six connections per host over HTTP/1.1. Each open
EventSourceuses one. Use one stream per page and multiplex event types on it. - Shutdown. A long-lived stream keeps its connection open while the server drains. End streams when the app shuts down by racing against
State<zapline::tasks::Shutdown>(stream.take_until(shutdown.wait())). See Background tasks. - Compression skips
text/event-streamby default, because encoders buffer. See Hardening middleware. - Timeouts.
Timeoutbounds the time to the response head, not the lifetime of the stream. - Authentication.
EventSourcecan't set headers, so authenticate with a cookie or a short-lived token in the query string. - Testing. The in-process client collects the whole body, so an infinite stream never finishes. Test with a finite stream (
stream::iter, or.take(3)):
#![allow(unused)] fn main() { use futures_util::stream::{self, Stream, StreamExt}; use zapline::prelude::*; use zapline::sse::{Event, Sse}; async fn ticks() -> Sse<impl Stream<Item = Event>> { Sse::new(stream::iter(1..=2).map(|n| Event::default().event("tick").data(n.to_string()))) } #[tokio::test] async fn ticks_are_events() { let res = App::new().get("/ticks", ticks).test().get("/ticks").send().await; assert_eq!(res.header("content-type"), Some("text/event-stream")); assert_eq!(res.text(), "event: tick\ndata: 1\n\nevent: tick\ndata: 2\n\n"); } }
StreamBody
StreamBody turns any stream of byte chunks into a response with chunked transfer encoding. Chunks can be anything convertible to Bytes (String, Vec<u8>, &'static str, Bytes), and an Err item aborts the response mid-stream. The default Content-Type is application/octet-stream:
#![allow(unused)] fn main() { use futures_util::stream; use zapline::prelude::*; use zapline::stream::StreamBody; async fn report() -> StreamBody<impl futures_util::Stream<Item = std::result::Result<String, std::io::Error>>> { let rows = (1..=1000).map(|id| Ok(format!("{id},row-{id}\n"))); StreamBody::new(stream::iter(rows)).content_type("text/csv") } }
An invalid content_type value turns the response into a 500 instead of panicking. To build the body yourself, Body::from_stream(stream) makes a Body for a plain Response.
Ndjson
Ndjson streams newline-delimited JSON (application/x-ndjson): each item is serialized as one compact JSON document followed by \n. Rows are serialized and sent as they are produced, so a million-row export needs constant memory.
#![allow(unused)] fn main() { use futures_util::stream::{self, StreamExt}; use serde::Serialize; use zapline::prelude::*; use zapline::stream::Ndjson; #[derive(Serialize)] struct Row { id: u32, square: u64, } async fn export() -> Ndjson { Ndjson::new(stream::iter(1..=1000u32).map(|id| Row { id, square: u64::from(id) * u64::from(id) })) } }
Ndjson::new(stream) takes infallible items; a serialization failure aborts the response. Ndjson::try_new(stream) takes Result<T, E> items for sources that can fail (a database cursor, say), and an Err aborts the response. Clients detect an aborted stream because the connection ends without a clean close, so make consumers tolerate a truncated last line.
BodyStream: large uploads
Json<T>, Form<T>, Bytes and String buffer the whole body, bounded by the app's body limit (2 MiB by default). BodyStream reads the request body chunk by chunk without buffering it, so a multi-gigabyte upload needs constant memory. It must be the last handler argument and implements Stream<Item = Result<Bytes>>, so futures_util::StreamExt works on it too.
#![allow(unused)] fn main() { use tokio::io::AsyncWriteExt; use zapline::prelude::*; use zapline::stream::BodyStream; #[put("/uploads/{name}")] async fn upload(Path(name): Path<u64>, body: BodyStream) -> Result<StatusCode> { let mut body = body.with_limit(1024 * 1024 * 1024); // 1 GiB for this route only let mut file = tokio::fs::File::create(format!("/var/uploads/{name}.bin")).await?; while let Some(chunk) = body.chunk().await? { file.write_all(&chunk).await?; } file.flush().await?; Ok(StatusCode::CREATED) } }
| Method | Effect |
|---|---|
chunk().await? | The next Bytes chunk, or None at the end of the body |
with_limit(bytes) | Raises or lowers the limit for this handler. The default is the app's body_limit (2 MiB), so raise it for large uploads. |
limit(), bytes_read() | The current limit and the number of bytes received so far |
The limit is enforced across chunks: once more bytes arrive than allowed, the stream yields a 413 Payload Too Large error and ends. A Content-Length that already exceeds the limit fails on the first read, before any bytes are consumed. For form uploads with metadata and several files, use Multipart instead (see Static files & uploads).
Live updates
zapline::live pushes changes to every open page as they happen: publish to a topic, and every browser subscribed to it gets the message. With htmx you write no JavaScript. It's always available (no feature flag).
#![allow(unused)] fn main() { use zapline::live::Live; use zapline::prelude::*; #[get("/live/chat")] async fn listen(live: Live) -> Response { live.sse("chat").into_response() } #[post("/chat")] async fn say(live: Live, views: Views, Form(msg): Form<Message>) -> Result<StatusCode> { let html = views.render("chat/message.html", context! { msg })?; live.publish("chat", "message", html.0); Ok(StatusCode::NO_CONTENT) } let app = App::new().live(Live::new()).mount((listen, say)); }
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js" defer></script>
<div hx-ext="sse" sse-connect="/live/chat" sse-swap="message" hx-swap="beforeend"></div>
sse-swap names the event to swap in. Here every message is appended to the <div>.
Projects created with zapline new --template fullstack already work this way: open the todo page in two tabs, change a todo in one, and the other updates. The handlers publish the re-rendered list block after each change.
The API
| Call | |
|---|---|
live.publish(topic, event, data) | Send to everyone on topic. Returns how many got it. |
live.publish_json(topic, event, &value) | The same, with value as JSON. |
live.sse(topic) | An SSE response streaming the topic, with a keep-alive every 15 s. |
live.subscribe(topic) | A Subscription: sub.next().await, or use it as a Stream. |
Live::new().capacity(n) | Messages kept for a subscriber that falls behind (default 256). |
Topics are just strings. Use room:42 or user:7 for one room or one user, and check access in the handler that calls live.sse (for example by taking a CurrentUser argument).
A topic exists while someone listens. A slow subscriber skips the messages it missed rather than slowing everyone else down. The dev console lists topics and their subscriber counts.
WebSockets
Forward a subscription to a socket:
#[get("/ws/chat")]
async fn ws(live: Live, upgrade: WebSocketUpgrade) -> Response {
upgrade.on_upgrade(move |mut socket| async move {
let mut sub = live.subscribe("chat");
while let Some(update) = sub.next().await {
if socket.send(Message::text(update.data)).await.is_err() {
break;
}
}
})
}
Several instances
Live::new() reaches the subscribers of one process. Behind a load balancer with several instances, use Live::new().redis(redis) (feature redis) so every update reaches visitors on every instance: see Running several instances.
Testing
Subscribe in the test and assert on what a handler published:
let client = app(Database::sqlite_memory()).test();
let live: Live = client.resources().await.require()?;
let mut updates = live.subscribe("todos");
client.post("/todos").form(&[("title", "x")]).send().await;
assert!(updates.next().await.unwrap().data.contains("x"));
Static files & uploads
Serve a directory with one line, and accept multipart/form-data uploads with an extractor:
#![allow(unused)] fn main() { use zapline::fs::{ServeDir, ServeFile}; use zapline::prelude::*; fn app() -> App { App::new() .static_files("/assets", ServeDir::new("public").cache_control("public, max-age=3600")) .get("/favicon.ico", ServeFile::new("public/favicon.ico")) } }
Static files need the Cargo feature fs (it adds mime_guess and tokio-util). Uploads need multipart (it adds multer). BodyStream for raw streamed uploads is always available; see Server-Sent Events & streaming.
ServeDir
App::static_files(prefix, ServeDir) (also Routes::static_files) mounts a directory under a prefix. It registers GET and HEAD on the prefix itself (the directory's index) and on prefix/{*path}. Mounted on a Routes group, the prefix nests like any route. ServeDir is also a plain handler, so it can be the app's fallback and serve every unmatched path:
#![allow(unused)] fn main() { use zapline::fs::ServeDir; use zapline::prelude::*; fn app() -> App { App::new() .get("/api/ping", || async { "pong" }) .fallback(ServeDir::new("dist")) // every other path comes from dist/ } }
| Method | Default | Effect |
|---|---|---|
ServeDir::new(root) | Serve files under root (relative to the working directory). The directory may be created after startup. | |
.index_file(name) | index.html | The file served for directory requests. A directory requested without a trailing slash is redirected (307) to add one, so relative links in the page resolve correctly. Behind NormalizePath::trim_trailing_slash() the index is served at /dir directly instead (see below). |
.no_index_file() | Directory requests answer 404 | |
.fallback_file(path) | none | Serve this file with 200 when the requested file doesn't exist. This is how single-page apps work. Relative to the root. |
.cache_control(value) | none | Sets Cache-Control on file responses. Panics on an invalid value. |
.precompressed_gzip() | off | Serve file.gz instead of file when the client accepts gzip and the compressed file exists |
.precompressed_br() | off | The same for file.br and Brotli. Preferred over gzip at equal q. |
.show_hidden(bool) | false | Serve dotfiles and files in dot-directories |
With NormalizePath
NormalizePath and the directory redirect disagree about trailing slashes, so ServeDir defers to it:
trim_trailing_slash()(the default) would turn the redirect target/guide/back into/guide, forever. Behind it,ServeDirservesguide/index.htmlat/guidedirectly, and/guide/gets the same page (rewritten, or redirected once to/guideinredirectmode). Relative links in such an index page resolve against the parent directory, so use absolute links there (or a<base href="/guide/">).append_trailing_slash()already agrees withServeDir:/guidebecomes/guide/, which serves the index.
Single-page apps
Build assets with a bundler that writes .br and .gz siblings, then serve them as they are, with a fallback to index.html for client-side routes:
#![allow(unused)] fn main() { use zapline::fs::ServeDir; use zapline::prelude::*; fn app() -> App { let spa = ServeDir::new("dist") .fallback_file("index.html") .precompressed_br() .precompressed_gzip() .cache_control("public, max-age=600"); App::new() .nest("/api", Routes::new().get("/ping", || async { "pong" })) .fallback(spa) } }
Unknown paths, /api/... included, now answer index.html with 200. If your API should keep returning JSON 404s, mount the SPA under its own prefix instead (static_files("/app", ..)).
What you get
Content-Typefrom the file extension (; charset=utf-8is added for text),application/octet-streamfor unknown ones.- Conditional requests. A weak
ETag(size and modification time) withIf-None-Match, andLast-ModifiedwithIf-Modified-Since, both answering304. - Ranges. A single byte range (
bytes=a-b,a-,-n) answers206withContent-Range, or416when unsatisfiable.If-Rangeis honored by date; since theETagis weak, anIf-Rangewith an entity tag returns the whole file. Requests with several ranges also get the whole file (200). - Precompressed variants chosen from
Accept-Encodingq-values, withContent-Encoding,Vary: accept-encodingand anETagthat differs per encoding. TheContent-Typecomes from the original file. HEADsupport with exactContent-Length. Files are streamed in 64 KiB chunks (small ones are read in one go), never buffered whole. Other methods answer405withAllow: GET, HEAD.
GET /app.js -> 200 ETag: W/"578-6ad7d21e.1e722142" Cache-Control: public, max-age=60
GET /app.js (If-None-Match: that ETag) -> 304
GET /app.js (Range: bytes=0-9) -> 206 Content-Range: bytes 0-9/1400
GET /app.js (Range: bytes=99999-) -> 416 Content-Range: bytes */1400
GET /sub -> 307 Location: /sub/
GET /.env -> 404
Security guarantees
Static file serving is a classic source of vulnerabilities, so ServeDir is conservative:
- Request paths are percent-decoded once, then rejected if any segment is
.., contains a backslash or NUL (or:on Windows), or is not a plain file name. - The resolved path is canonicalized and must stay inside the canonicalized root, so symlinks cannot escape it. The same applies to precompressed siblings.
- Dotfiles (and anything inside dot-directories) are refused unless
show_hidden(true)..envand.git/never leak. - Directories are never listed.
- All rejected paths answer an ordinary
404, so probing reveals nothing. Redirects are built so they can never turn into//hostURLs.
Things the framework cannot decide for you:
- Everything under the root is public. Don't point
ServeDirat a directory that mixes public and private files. - If users can upload
.htmlor.svgfiles and you serve them back from your own origin, a script in the file runs with your origin's privileges (stored XSS). Serve user uploads from a separate domain, restrict the extensions you accept, or store files under generated names and send a downloadContent-Dispositionthrough your own handler.SecurityHeadersalready addsX-Content-Type-Options: nosniff. - File serving does a short blocking filesystem lookup on a worker thread (
spawn_blocking), once per request. Put a CDN or reverse proxy in front for hot assets.
Fingerprinted assets
App::assets(prefix, dir) serves a directory like static_files, and also gives every file a URL with a hash of its content, so browsers can cache it forever and still get the new version the moment it changes:
#![allow(unused)] fn main() { use zapline::fs::Assets; use zapline::prelude::*; #[get("/")] async fn home(assets: Assets) -> Html<String> { // "/static/app.3f2a1b9c.css" Html(format!(r#"<link rel="stylesheet" href="{}">"#, assets.url("app.css"))) } fn app() -> App { App::new().assets("/static", "static").mount(home) } }
In templates (feature views) it is a function, whether .views(..) comes before or after .assets(..):
<link rel="stylesheet" href="{{ asset('app.css') }}">
<script src="{{ asset('js/app.js') }}" defer></script>
/static/app.3f2a1b9c.cssis served withCache-Control: public, max-age=31536000, immutable.- An old hash still serves today's file, with
no-cache, so pages cached before a deploy keep working. /static/app.css(no hash) is still served, without the long cache.- A file that does not exist keeps its plain URL; hidden files stay hidden.
- Hashes are 8 hex digits of the content. In debug builds (and under
zapline dev, even with--release) they are recomputed when a file's size or modification time changes, so edits show up at once; release builds hash each file once.
No build step, bundler or manifest file: the hash is computed by the server on first use. The fullstack template uses it for app.css.
App::assets("/static", zapline::embed!("static")) (and ServeDir::new(zapline::embed!("public"))) serve files compiled into the binary instead: same URLs, hashes, ETags, ranges and precompressed variants, with the hashes computed at compile time. Debug builds keep reading the directory (One binary).
Tailwind CSS without Node
zapline g tailwind
This writes styles/app.css and compiles it to static/app.css with Tailwind's official standalone binary. zapline downloads it once into your user cache (~/.cache/zapline), pinned to one version and checked against its SHA-256 before it runs; set ZAPLINE_TAILWIND=/path/to/tailwindcss to use your own. No Node, npm or package.json.
zapline devruns Tailwind's watcher alongside the app: add a class intemplates/and open pages reload, then get the rebuiltstatic/app.cssswapped in without another reload (the dev loop).zapline cssbuilds once;zapline css --minifyfor a smaller file before you deploy.- Commit
static/app.css. It is what the server (and the Docker image) serves, so deploys need no Tailwind.
What the generator does with what you already had:
- The CSS that was in
static/app.cssmoves intostyles/app.css, in@layer components, so utility classes can override it. - Stylesheets your
templates/base.htmlloaded from a CDN (Pico, in thefullstacktemplate) move intostyles/app.cssas@import url(..) layer(base). They keep styling plain elements in place of Tailwind's reset, and utilities win over them. For Tailwind alone, replace the lines above@sourcewith@import "tailwindcss";.
ServeFile
ServeFile::new(path) serves one file, whatever the request path. It is a handler, so it goes anywhere a handler goes. It has the same ETag, range, HEAD and precompression behavior, and .cache_control(..), .precompressed_gzip() and .precompressed_br(). A missing file answers 404.
#![allow(unused)] fn main() { use zapline::fs::ServeFile; use zapline::prelude::*; fn app() -> App { App::new() .get("/favicon.ico", ServeFile::new("public/favicon.ico")) .get("/robots.txt", ServeFile::new("public/robots.txt").cache_control("public, max-age=86400")) } }
Uploads with Multipart
Multipart is the extractor for multipart/form-data bodies, the format HTML file forms send. It parses the body as a stream of fields, without ever buffering it whole, and must be the last handler argument:
#![allow(unused)] fn main() { use zapline::multipart::Multipart; use zapline::prelude::*; #[post("/upload")] async fn upload(mut form: Multipart) -> Result<String> { let mut summary = Vec::new(); while let Some(field) = form.next_field().await? { let name = field.name().unwrap_or("").to_owned(); match field.file_name().map(str::to_owned) { Some(file) => { let data = field.bytes().await?; summary.push(format!("{name}: {file} ({} bytes)", data.len())); } None => summary.push(format!("{name} = {}", field.text().await?)), } } Ok(summary.join("\n")) } }
Only one field can be read at a time: a Field borrows the Multipart, so consume or drop it before calling next_field again. Read a field whole, chunk by chunk, or straight to disk:
Field method | Returns |
|---|---|
name() | The form field name |
file_name() | The client-supplied file name. Untrusted input: never use it as a path directly. |
safe_file_name() | The file name reduced to a single safe path component: directories, control characters and \ / : * ? " < > | removed, at most 255 bytes. None if nothing usable remains. |
content_type(), headers(), index() | The part's Content-Type, all its headers, its position in the body |
bytes(), text(), json::<T>() | The whole field, buffered (text decodes with the part's charset, UTF-8 by default; json needs the json feature and fails with 422) |
chunk() | The next Bytes, or None at the end (a Field is also a Stream) |
save_to(path) | Streams the field into a new file at path (created or truncated) and returns the number of bytes written |
A complete handler that stores files safely:
#![allow(unused)] fn main() { use std::path::PathBuf; use serde::Serialize; use zapline::multipart::{Multipart, MultipartConfig}; use zapline::prelude::*; #[derive(Clone)] struct UploadDir(PathBuf); #[derive(Serialize)] struct Uploaded { fields: Vec<(String, String)>, files: Vec<(String, u64)>, } async fn upload(State(dir): State<UploadDir>, mut form: Multipart) -> Result<Json<Uploaded>> { let mut out = Uploaded { fields: Vec::new(), files: Vec::new() }; while let Some(field) = form.next_field().await? { let name = field.name().unwrap_or_default().to_owned(); if field.file_name().is_some() { // Never trust the client's file name as a path. let Some(file_name) = field.safe_file_name() else { return Err(Error::bad_request("invalid file name")); }; let size = field.save_to(dir.0.join(&file_name)).await?; out.files.push((file_name, size)); } else { out.fields.push((name, field.text().await?)); } } Ok(Json(out)) } fn app() -> App { App::new() .state(UploadDir(PathBuf::from("uploads"))) .post("/upload", upload) .meta(MultipartConfig::new().body_limit(50 * 1024 * 1024).max_fields(20)) } }
Even a safe name can collide with an existing upload. For user content, prefer generating the stored name yourself (a UUID or a hash) and keeping the original name as metadata.
Limits
By default the whole body is bounded by the app's body limit (App::body_limit, 2 MiB), which is far too small for uploads. A MultipartConfig sets the limits, and it is looked up first from the matched route's metadata (.meta(..) after the route), then from app state (.state(..)). Put app-wide defaults in state and override them per route:
| Method | Effect |
|---|---|
.body_limit(bytes) | Maximum size of the whole request body (default: the app's body limit) |
.max_field_size(bytes) | Maximum size of any single field |
.field_limit(name, bytes) | Maximum size of the field called name. Overrides max_field_size. |
.allowed_fields([..]) | Only accept these field names; others fail with 400 |
.max_fields(n) | Maximum number of fields; more fail with 413 |
#![allow(unused)] fn main() { use zapline::multipart::{Multipart, MultipartConfig}; use zapline::prelude::*; async fn upload(_form: Multipart) {} fn app() -> App { App::new() // App-wide defaults... .state(MultipartConfig::new().max_field_size(256 * 1024)) // ...and a per-route override for the big upload endpoint. .post("/videos", upload) .meta( MultipartConfig::new() .body_limit(512 * 1024 * 1024) .field_limit("video", 512 * 1024 * 1024) .allowed_fields(["title", "video"]) .max_fields(4), ) } }
A route-level MultipartConfig replaces the app-level one completely; it doesn't merge with it.
Errors are ordinary zapline::Errors with stable codes:
| Problem | Status | Code |
|---|---|---|
Not multipart/form-data | 415 | multipart_unsupported_media_type |
| Missing boundary | 400 | multipart_missing_boundary |
| Malformed body | 400 | multipart_invalid |
Field not in allowed_fields | 400 | multipart_unknown_field |
| Body too large | 413 | none |
| A field exceeds its limit | 413 | multipart_field_too_large |
| Too many fields | 413 | multipart_too_many_fields |
A Content-Length larger than the body limit is rejected up front, before any byte is read. Behind a reverse proxy, remember its own body-size limit too (nginx's client_max_body_size defaults to 1 MB).
Testing uploads
The test client sends raw bodies, so build the multipart payload by hand:
#![allow(unused)] fn main() { use zapline::multipart::Multipart; use zapline::prelude::*; async fn upload(mut form: Multipart) -> Result<String> { let mut names = Vec::new(); while let Some(field) = form.next_field().await? { names.push(field.name().unwrap_or("").to_owned()); } Ok(names.join(",")) } #[tokio::test] async fn upload_reads_all_fields() { let body = "--X\r\nContent-Disposition: form-data; name=\"note\"\r\n\r\nhi\r\n\ --X\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.txt\"\r\n\r\ndata\r\n--X--\r\n"; let res = App::new() .post("/upload", upload) .test() .post("/upload") .header("content-type", "multipart/form-data; boundary=X") .body(body) .send() .await; assert_eq!(res.text(), "note,file"); } }
File storage
zapline::storage keeps files under string keys like avatars/42/me.png. The same code works with a local directory while developing, memory in tests, and S3 (AWS, Cloudflare R2, MinIO, Backblaze B2) in production.
use std::time::Duration; use zapline::multipart::Multipart; use zapline::prelude::*; use zapline::storage::{unique_key, Storage}; #[post("/avatar")] async fn upload(storage: Storage, mut form: Multipart) -> Result<String> { let Some(file) = form.next_field().await? else { return Err(Error::bad_request("no file")); }; let key = unique_key("avatars", file.file_name().unwrap_or("upload")); storage.put(&key, file.bytes().await?).await?; Ok(storage.url(&key, Duration::from_secs(3600))) } fn main() -> zapline::Result<()> { App::new() .storage(Storage::from_env()?) .mount(upload) .run("0.0.0.0:8080")?; Ok(()) }
Requirements: the storage feature (included in full), plus s3 for S3.
The API
| Call | Does |
|---|---|
storage.put(key, bytes) | Store (or replace) a file. The content type comes from the key's extension. Local writes are atomic. |
storage.put_with(key, bytes, PutOptions::new().content_type(..).cache_control(..)) | The same, with the content type and Cache-Control you choose. |
storage.put_stream(key, stream, options) | Store a stream of chunks (for example a request's BodyStream) without holding it in memory. Returns the size. |
storage.get(key) | The bytes, or a 404 error. |
storage.get_stream(key) | A Body that streams the file (64 KiB chunks from disk, straight from S3): return it from a handler. |
storage.exists(key) | true or false. |
storage.metadata(key) | ObjectInfo { key, size, modified, content_type, cache_control }, without reading the file. |
storage.list(prefix) | Every file whose key starts with prefix, sorted, as ObjectInfos ("" lists everything). |
storage.copy(from, to) | Copy a file with its content type (inside the bucket on S3, nothing downloaded). |
storage.delete(key) | Remove it. Removing a missing file is fine. |
storage.url(key, ttl) | A link that works for ttl. |
storage.upload_url(key, ttl, max_bytes, content_type) | A URL the browser uploads one file to directly (below). |
unique_key(prefix, file_name) | prefix/<random>/<safe-name>: a fresh key for an upload, with a name that can't escape the prefix. |
Invalid keys (empty segments, .., a leading /, backslashes, a segment starting with .zl-, over 1024 bytes) are a 400 with code invalid_key, so a key built from user input can't reach outside the storage. Prefixes given to list are checked the same way. The local backend keeps its temporary files and the options of put_with next to each file under .zl- names, which never appear in listings; listings don't follow symbolic links.
Links are private
Files aren't public. storage.url makes a link that expires:
- S3: a presigned URL (at most 7 days), straight to the bucket.
- Local and memory:
/files/<key>?expires=…&sig=…, served by the app (App::storagemounts it) only after the HMAC signature and the expiry check out. Anything else is a403. Files are streamed with the content type andCache-Controlthey were stored with (defaultprivate, max-age=300),X-Content-Type-Options: nosniffand a sandboxingContent-Security-Policy; anything but images, PDFs, plain text, audio and video downloads instead of opening, whatever its declared type.
Local links are signed with a random key per process, so they stop working at restart. Set STORAGE_SECRET (or call .secret(..)) to keep them valid, and use the same value on every instance.
Large files
get_stream and put_stream move a file without loading it into memory, so a handler can pass a multi-gigabyte upload straight through:
#![allow(unused)] fn main() { use zapline::storage::{unique_key, PutOptions, Storage}; use zapline::stream::BodyStream; use zapline::Body; #[put("/videos/{name}")] async fn upload(storage: Storage, Path(name): Path<String>, body: BodyStream) -> Result<String> { let key = unique_key("videos", &name); let options = PutOptions::new().content_type("video/mp4"); storage.put_stream(&key, body.with_limit(2 << 30), options).await?; Ok(key) } #[get("/exports/{name}")] async fn export(storage: Storage, Path(name): Path<String>) -> Result<Body> { storage.get_stream(&format!("exports/{name}")).await } }
Locally the stream goes to a temporary file that replaces the old one only when complete. On S3 anything over 8 MiB becomes a multipart upload in 8 MiB parts; if the stream fails (a BodyStream over its limit, a dropped connection) the upload is aborted, nothing is stored, and the stream's error (the 413, say) is what put_stream returns.
Direct uploads from the browser
upload_url lets the browser send a file to storage without it passing through your handlers. The handler only decides the key, the size limit and the type:
#![allow(unused)] fn main() { use zapline::storage::{unique_key, Storage, UploadUrl}; #[post("/avatar/upload-url")] async fn avatar_upload_url(storage: Storage, user: CurrentUser) -> Result<Json<UploadUrl>> { let key = unique_key(&format!("avatars/{}", user.0.id), "avatar.png"); let up = storage.upload_url(&key, Duration::from_secs(600), 5 << 20, "image/png")?; Ok(Json(up)) } }
const up = await (await fetch("/avatar/upload-url", { method: "POST" })).json();
await fetch(up.url, { method: up.method, headers: up.headers, body: file });
// then tell the app that up.key is ready
- S3: a presigned
PUT(at most 7 days) that signs the content type, so S3 refuses any other. S3 can't cap the size of a presignedPUT: when the browser reports success, checkstorage.metadata(&key)and delete what is too large. - Local and memory: a
PUTto/files/<key>?expires=…&max=…&type=…&sig=…, whichApp::storageserves. The HMAC signature covers the key, the expiry, the size limit and the type, so none of them can be changed. It answers403for a bad or expired signature (download links don't work for uploads, nor the other way round),415for anotherContent-Type, and413beyondmax_bytes, checked againstContent-Lengthand again while the body streams to disk.
Configuration
Storage::from_env() picks S3 when S3_BUCKET is set, otherwise a local directory:
| Variable | Default | |
|---|---|---|
STORAGE_DIR | storage | Local directory (created as needed). |
STORAGE_SECRET | random | Signs local links. |
S3_BUCKET | Turns on S3. | |
S3_REGION / AWS_REGION | us-east-1 | Use auto for R2. |
S3_ENDPOINT | AWS | For S3-compatible services, e.g. https://<account>.r2.cloudflarestorage.com or http://localhost:9000. Uses path-style URLs. |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | Required for S3. AWS_SESSION_TOKEN is optional. |
Or build one in code: Storage::s3(S3::new("bucket", "eu-west-1").credentials(id, secret)).
The S3 client is built in (Signature V4 over hyper and rustls; the signing is tested against AWS's published examples and botocore), so it adds no AWS SDK to your build. It covers what the API needs: objects, ListObjectsV2 with continuation tokens, server-side copies, multipart uploads, and presigned GET and PUT.
Testing
Use Storage::memory():
let storage = Storage::memory();
let client = app().storage(storage.clone()).test();
client.post("/avatar").multipart(form).send().await.assert_status(200);
assert!(storage.exists("avatars/...").await?);
Observability: tracing, metrics, health
Three plugins give you logs, metrics and probes. Each is one line, and they work together:
use std::time::Duration; use zapline::health::Health; use zapline::metrics::Metrics; use zapline::prelude::*; use zapline::telemetry::{self, Trace}; fn main() -> std::io::Result<()> { telemetry::init(); // RUST_LOG-aware subscriber App::new() .get("/users/{id}", |Path(id): Path<u64>| async move { format!("user {id}") }) .plugin(Trace::new().skip_paths(["/health/live", "/metrics"])) // a span + one event per request .plugin(Metrics::new()) // GET /metrics (Prometheus) .plugin(Health::new().timeout(Duration::from_secs(1))) // /health/live, /health/ready, /health .run("0.0.0.0:8080") }
| Plugin | Needs | Serves |
|---|---|---|
telemetry::init(), Telemetry, Trace | Cargo feature tracing (on by default; adds tracing and tracing-subscriber) | nothing: logs and spans |
Metrics | nothing (always compiled, no extra dependencies) | GET /metrics |
Health | nothing | GET /health/live, /health/ready, /health |
Logging with Telemetry
telemetry::init() installs a global subscriber: it honors RUST_LOG (default info), prints pretty output on a terminal and compact output otherwise, and does nothing if a subscriber is already installed. For production, choose the format and level explicitly:
use zapline::telemetry::Telemetry; fn main() { Telemetry::new() .json() // newline-delimited JSON for log pipelines .level("info") // used when RUST_LOG is unset .with_target(false) .init(); }
| Method | Effect |
|---|---|
.format(Format) / .json() / .pretty() / .compact() | Output format. Format::Auto (the default) uses ZAPLINE_LOG_FORMAT (json, pretty, compact, full) if set, otherwise pretty on a terminal and compact elsewhere. |
.level(level) | Default level when RUST_LOG is unset ("debug", Level::WARN, ...) |
.env_filter(directives) | Explicit directives such as "info,my_app=debug,hyper=warn". Takes precedence over RUST_LOG. |
.env_var(name) | Read the directives from this variable instead of RUST_LOG |
.with_target(bool) | Show the event target (default true) |
.ansi(bool) | Force colors on or off. By default they are on for terminals unless NO_COLOR is set, and never for JSON. |
.with_thread_ids(bool), .with_thread_names(bool), .with_file_line(bool) | Extra fields |
.writer(w) | Write somewhere other than stdout, such as std::io::stderr or a non-blocking file appender |
.init() | Installs the subscriber. Does nothing if one exists. Panics on invalid filter directives, which is a startup configuration error. |
.try_init() | The same, returning Result<(), TelemetryError> (AlreadyInitialized or InvalidFilter). It also bridges log records. |
.into_dispatch() | Builds the subscriber without installing it, for tracing::dispatcher::with_default in tests |
The filter is env_filter(..), else RUST_LOG when non-empty, else level(..), else info.
Log from handlers with the re-exported macros (zapline::telemetry::{debug, info, warn, error, trace}), or depend on tracing directly. Events emitted inside a request are nested in its span, so they carry the route and the request ID:
#![allow(unused)] fn main() { use zapline::prelude::*; use zapline::telemetry::{info, warn}; #[post("/orders")] async fn create_order(Json(sku): Json<String>) -> Result<StatusCode> { if sku.is_empty() { warn!("rejected an empty sku"); return Err(Error::unprocessable("sku must not be empty").with_code("empty_sku")); } info!(%sku, "order created"); Ok(StatusCode::CREATED) } }
Request tracing with Trace
Trace creates one request span per request and one completion event. The span records:
| Field | Value |
|---|---|
http.method | GET, POST, ... |
http.route | The route template (/users/{id}), or unmatched for requests that matched no route |
url.path | The raw path |
client.address | The peer IP address |
request_id | The x-request-id request header, or else the response header (as set by SetRequestId) |
http.status_code, latency_ms | Recorded when the response is ready |
The completion event (request completed) is INFO below 400, WARN for 4xx and ERROR for 5xx. Its target is zapline::request, so RUST_LOG=zapline::request=warn quiets the access log without touching the rest. This is what a JSON line looks like (a single line in reality):
{"timestamp":"2026-10-20T20:36:25.390523Z","level":"INFO","message":"request completed",
"http.status_code":200,"latency_ms":0.262,"target":"zapline::request",
"span":{"client.address":"127.0.0.1","http.method":"GET","http.route":"/users/{id}",
"request_id":"abc-123","url.path":"/users/42","name":"request"},"spans":[]}
There are three ways to install it:
| Install | Sees | http.route |
|---|---|---|
.plugin(Trace::new()) (recommended) | Every request, including 404s | The route template, or unmatched |
.wrap(Trace::new()) | Every request, including 404s | Not recorded (routing happens later) |
.wrap_route(Trace::new()) | Matched routes only | The route template |
The plugin form adds a global middleware at the position of the .plugin(..) call plus a tiny route middleware that records the template. Put it after SetRequestId so the ID is known, and outside CatchPanic so panics are logged as 500s (see the production stack).
| Method | Effect |
|---|---|
.level(Level) | Level of the span and of success events (default INFO) |
.level_for(StatusClass, Level) | Level of the completion event for Success, ClientError or ServerError responses |
.skip_paths(paths) | Request paths or route templates to skip entirely, such as probes and scrapes. A trailing * matches a prefix: "/static/*". |
.request_id_header(name) | Header carrying the request ID (default x-request-id) |
.client_ip_header(name) | Take client.address from the first entry of this header (for example x-forwarded-for). Only behind a proxy that sets it, since clients can forge it. |
#![allow(unused)] fn main() { use zapline::prelude::*; use zapline::telemetry::{Level, StatusClass, Trace}; fn app() -> App { App::new().get("/", || async { "hi" }).plugin( Trace::new() .level(Level::DEBUG) // spans and success events at debug .level_for(StatusClass::ClientError, Level::INFO) .skip_paths(["/health/live", "/metrics", "/static/*"]), ) } }
When the subscriber filters out every level Trace could emit, it costs one level comparison per request. The simpler Logger middleware prints one line per request to stderr and needs no setup; prefer Trace in services.
Traces to OpenTelemetry (OTLP)
With the otlp feature, spans go to any OpenTelemetry collector or backend that accepts OTLP/HTTP (Jaeger, Grafana Tempo, Honeycomb, Datadog, the OpenTelemetry Collector...), with no OpenTelemetry SDK in your dependencies. Set the standard variable and that's it:
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 OTEL_SERVICE_NAME=shop cargo run
telemetry::init() (and any Telemetry) picks it up. Or configure it in code:
#![allow(unused)] fn main() { Telemetry::new() .otlp("http://localhost:4318") // or the full .../v1/traces URL .service_name("shop") .init(); }
| Variable | Use |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | Collector base URL; traces go to {url}/v1/traces |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | Full traces URL (wins over the above) |
OTEL_EXPORTER_OTLP_HEADERS | key=value,key2=value2, e.g. an API key (values may be percent-encoded) |
OTEL_SERVICE_NAME | service.name (default: the executable's name) |
OTEL_RESOURCE_ATTRIBUTES | Extra resource attributes, deployment.environment=prod,... |
What you get:
- Each request
Tracerecords becomes a server span named likeGET /users/{id}, withhttp.request.method,http.route,url.path,http.response.status_codeandclient.address. 5xx responses are marked as errors. - Your own
tracingspans (info_span!("load_user"),#[instrument]) become child spans, and log events inside them become span events. - A request with a W3C
traceparentheader continues that trace, so a call from another traced service shows up as one trace.
Spans are exported from a background thread in batches of up to 512, at least every second. If the collector is down, a warning is printed once and spans beyond 8,192 waiting are dropped, so the app never blocks or grows without bound. Only the spans your log filter enables are exported: with RUST_LOG=warn, request spans (INFO) are filtered out too.
Metrics
Metrics instruments every route and serves a Prometheus scrape endpoint. Install it with .plugin(Metrics::new()), then curl localhost:8080/metrics:
# HELP http_requests_total Total number of HTTP requests.
# TYPE http_requests_total counter
http_requests_total{method="GET",route="/users/{id}",status="200"} 1
http_requests_total{method="GET",route="unmatched",status="404"} 1
# HELP http_request_duration_seconds HTTP request latency in seconds.
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",route="/users/{id}",le="0.005"} 1
...
http_request_duration_seconds_bucket{method="GET",route="/users/{id}",le="+Inf"} 1
http_request_duration_seconds_sum{method="GET",route="/users/{id}"} 0.000038321
http_request_duration_seconds_count{method="GET",route="/users/{id}"} 1
# HELP http_requests_in_flight HTTP requests currently being served.
# TYPE http_requests_in_flight gauge
http_requests_in_flight 1
# HELP process_resident_memory_bytes Resident memory size in bytes.
# TYPE process_resident_memory_bytes gauge
process_resident_memory_bytes 7487488
| Series | Type | Labels |
|---|---|---|
http_requests_total | counter | method, route, status |
http_request_duration_seconds | histogram | method, route |
http_requests_in_flight | gauge | |
process_start_time_seconds | gauge | |
process_cpu_seconds_total, process_resident_memory_bytes, process_virtual_memory_bytes, process_open_fds, process_max_fds | Linux only. Read from /proc at scrape time, so they cost nothing between scrapes. | |
jobs_enqueued_total, jobs_started_total, jobs_succeeded_total, jobs_failed_total, jobs_retried_total, jobs_dead_total, jobs_duration_seconds | counters, histogram | job. Only when the app has durable jobs. |
scheduled_task_runs_total, scheduled_task_duration_seconds | counter, histogram | task (and outcome). Only when the app has scheduled tasks. |
route is always the route template (/users/{id}), never the raw path, so cardinality is bounded by your route table. Requests that match no route are counted with route="unmatched", and non-standard methods are reported as _OTHER.
The hot path is built for thread-per-core: per-route series are created at startup, counters are striped across cache lines, and recording a request takes no locks and does not hash strings.
| Method | Effect |
|---|---|
.path(path) | Scrape endpoint path (default /metrics) |
.endpoint(false) | Don't register the endpoint. Expose the metrics yourself with MetricsHandle. |
.prefix(prefix) | Metric name prefix (default http, giving http_requests_total). "api" gives api_requests_total. |
.buckets([..]) | Latency histogram upper bounds in seconds (default 5 ms to 10 s). Must be finite and strictly increasing, or the app panics at startup. |
.exclude_paths(paths) | Route templates (or, for unmatched requests, raw paths) not to record. A trailing * matches a prefix. |
.registry(Registry) | Render this registry's custom metrics instead of the global one |
.track_unmatched(false) | Don't count unmatched requests. This avoids one global middleware and keeps the router's no-middleware fast path for 404s. |
.process_metrics(false) | Leave out the process_* series |
Install Metrics after App::error_handler and any middleware that replaces responses, so it sees the router's own 404 and 405 responses.
Custom metrics
The global metrics::registry() is rendered after the HTTP and process metrics. Register counters, gauges and histograms once and keep the handle for hot paths. Asking for the same name, type and labels again returns the existing metric, so lazy registration is safe:
#![allow(unused)] fn main() { use std::time::Duration; use zapline::metrics::{self, Counter, Gauge, Histogram}; use zapline::prelude::*; #[derive(Clone)] struct ShopMetrics { orders: Counter, stock: Gauge, checkout: Histogram, } impl ShopMetrics { fn new() -> Self { let registry = metrics::registry(); ShopMetrics { orders: registry.counter("shop_orders_total", "Orders placed.", &["sku"]), stock: registry.gauge("shop_stock_units", "Units in stock.", &[]), checkout: registry.histogram_with_buckets( "shop_checkout_seconds", "Checkout duration.", &[], &[0.05, 0.1, 0.25, 0.5, 1.0, 2.5], ), } } } async fn order(State(m): State<ShopMetrics>, Json(sku): Json<String>) -> StatusCode { let _timer = m.checkout.start_timer(); // observes the elapsed time when dropped m.orders.with_labels(&[&sku]).inc(); // one series per label combination m.stock.dec(); tokio::time::sleep(Duration::from_millis(1)).await; StatusCode::CREATED } fn app() -> App { App::new().state(ShopMetrics::new()).post("/orders", order) } }
| Type | Methods |
|---|---|
Counter | inc(), add(n), get() |
Gauge | set(v), add(v), sub(v), inc(), dec(), get() |
Histogram | observe(v), start_timer() (a guard that observes on drop; .stop() returns the seconds, .discard() skips it), count(), sum() |
| any of them | with_labels(&["value", ..]): the series for these values, in label-name order |
Rules and safety nets:
- Registering a name again with a different type or different label names panics. So do invalid metric or label names (
leis reserved for histograms). - Each metric family holds at most 10,000 label combinations (
MAX_SERIES_PER_FAMILY). Further combinations fold into a single series whose label values are all_overflow, so a label fed with user input cannot exhaust memory. Still, never use unbounded values (user IDs, raw paths) as labels. - For values computed at scrape time (queue depths, pool sizes), implement
Collectoror pass a closureFn(&mut Encoder)toregistry.register(..). It writes complete metric families without.header(name, help, kind)andout.sample(name, &[(label, value)], v). Registry::new()makes a private registry, which you hand toMetrics::registry(..).registry.render()returns the text, which is useful in tests.
Protecting the endpoint
/metrics is public by default and reveals route names and traffic. Don't expose it to the internet. Either block it at the ingress or serve it behind auth, using MetricsHandle (registered as state by the plugin):
#![allow(unused)] fn main() { use zapline::auth::BasicAuth; use zapline::metrics::{Metrics, MetricsHandle}; use zapline::prelude::*; async fn scrape(auth: BasicAuth, State(metrics): State<MetricsHandle>) -> Result<Response> { if !auth.verify("prometheus", "scrape-password") { return Err(auth.reject()); } Ok(metrics.into_response()) } fn app() -> App { App::new() .plugin(Metrics::new().endpoint(false)) .get("/admin/metrics", scrape) } }
Health checks
Health serves the three endpoints Kubernetes and load balancers expect:
| Endpoint | Behavior |
|---|---|
GET /health/live | Always 200 {"status":"up"}. Runs no checks. If this fails, the process is wedged. |
GET /health/ready | Runs every check concurrently, each with a timeout. 200 when all critical checks pass (up, or degraded when a non-critical check fails), 503 otherwise or while the app is marked not ready. |
GET /health | The readiness report plus info and uptime_seconds |
#![allow(unused)] fn main() { use std::time::Duration; use zapline::health::Health; use zapline::prelude::*; #[derive(Clone)] struct Db; impl Db { async fn ping(&self) -> Result<()> { Ok(()) } } fn app() -> App { App::new().state(Db).plugin( Health::new() .info("orders", env!("CARGO_PKG_VERSION")) .check("db", |res: Resources| async move { res.require::<Db>()?.ping().await }) .check("cache", |_res: Resources| async move { Ok(()) }) .critical(false) // a failing cache degrades, but stays ready .timeout(Duration::from_secs(1)), ) } }
With the cache failing, GET /health/ready answers 200 (the error field appears only with .show_errors(true)):
{"status":"degraded","checks":{"db":{"status":"up","duration_ms":0.006},"cache":{"status":"down","duration_ms":52.576,"error":"cache unreachable"}},"info":{"name":"orders","version":"1.2.3"}}
A failing critical check gives {"status":"down", ...} with 503. Responses carry Cache-Control: no-store.
| Method | Effect |
|---|---|
.check(name, check) | Adds a named readiness check, critical by default. Panics on a duplicate name. |
.critical(bool) | Whether the most recently added check is critical. A failing non-critical check reports degraded and keeps the app ready. |
.timeout(duration) | Per-check timeout (default 2 s). A timed-out check is reported as down: timed out after 1s. |
.info(name, version) | Static service info in the reports |
.details(bool) | Include per-check results: name, status and duration (default true). When it's off, only the overall status is exposed. |
.show_errors(bool) | Include each failing check's error text (default false). Error text can reveal internal hostnames and library messages, so enable it only on endpoints that aren't publicly reachable. Failures are always logged. |
.cache_for(ttl) | Reuse the last readiness result for this long, so frequent probes don't hammer dependencies |
.live_path(p), .ready_path(p), .path(p) | Change the paths. "" disables an endpoint. |
.state(HealthState) | Use an existing HealthState |
Each check receives Resources, so it can reach state, providers (res.dep::<T>()) and anything a startup hook registered. A check that returns an error, times out or panics is reported as down; the request itself still succeeds. For reusable checks, implement the HealthCheck trait on a struct:
#![allow(unused)] fn main() { use zapline::health::HealthCheck; use zapline::{Error, Resources, Result}; struct DiskSpace { min_free_bytes: u64, } impl HealthCheck for DiskSpace { async fn check(&self, _res: Resources) -> Result<()> { let free = 10 * 1024 * 1024 * 1024u64; // e.g. from statvfs if free < self.min_free_bytes { return Err(Error::service_unavailable("low disk space")); } Ok(()) } } }
Readiness toggling
HealthState is registered as state by the plugin. While set_ready(false) is in effect, /health/ready answers 503 {"status":"down","reason":"not ready"} without running checks, and liveness is unaffected. Use it to stay out of rotation during warm-up or before a deploy:
#![allow(unused)] fn main() { use zapline::health::{Health, HealthState}; use zapline::prelude::*; #[post("/admin/drain")] async fn drain(State(health): State<HealthState>) -> &'static str { health.set_ready(false); "draining" } fn app() -> App { App::new() .plugin(Health::new()) .mount(drain) .on_startup(|res: Resources| async move { // Not ready until caches are warm; liveness stays up. let health = res.require::<HealthState>()?; health.set_ready(false); tokio::spawn(async move { // ... warm caches ... health.set_ready(true); }); Ok(()) }) } }
Kubernetes and Prometheus
Point the probes at the health plugin and let Prometheus scrape /metrics:
containers:
- name: orders
ports: [{ containerPort: 8080 }]
livenessProbe:
httpGet: { path: /health/live, port: 8080 }
periodSeconds: 10
readinessProbe:
httpGet: { path: /health/ready, port: 8080 }
periodSeconds: 5
startupProbe:
httpGet: { path: /health/live, port: 8080 }
failureThreshold: 30
periodSeconds: 2
# prometheus.yml
scrape_configs:
- job_name: orders
metrics_path: /metrics
kubernetes_sd_configs: [{ role: pod }]
Useful queries:
# requests per second by route
sum by (route) (rate(http_requests_total[5m]))
# 5xx error ratio
sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))
# 99th percentile latency per route
histogram_quantile(0.99, sum by (le, route) (rate(http_request_duration_seconds_bucket[5m])))
Notes for a clean setup:
- Don't make the liveness probe depend on dependencies: a database outage would restart every pod. Dependencies belong in readiness.
- Keep probes and scrapes out of the request log with
Trace::skip_paths([..]), and out of the metrics withMetrics::exclude_paths(["/health/*"]). - Rate limiting and authentication middleware installed with
App::wrap_routealso cover the plugin routes. Exempt probes, or install such middleware per group; see Hardening middleware. - The plugin routes appear in the OpenAPI document like any other route; see OpenAPI & interactive docs.
- The drain and termination settings that go with these probes are in Deployment.
Server: TLS, HTTP/2, HTTP/3, graceful shutdown
App::run(addr) starts a production-ready server with no extra configuration:
- Thread-per-core. One single-threaded Tokio runtime per CPU, each with its own
SO_REUSEPORTlistener. This is the default on Linux, Android and FreeBSD. Elsewhere, or with.runtime(RuntimeMode::MultiThread), it uses one listener on a work-stealing runtime. - Graceful shutdown. On SIGINT or SIGTERM (Ctrl-C on Windows) it stops accepting connections, lets in-flight requests finish, closes keep-alive connections after their next response (or after
drain_grace, 1s, if idle), waits up toshutdown_timeout(default 30s), then runs your shutdown hooks. A second signal forces an immediate stop. - HTTP/1.1 with keep-alive and pipelining. HTTP/2 with the
http2feature, using h2c prior knowledge and ALPNh2over TLS. Detection costs nothing for HTTP/1.1 connections. WebSockets work over both (RFC 8441 for HTTP/2). - HTTP/3 (experimental) over QUIC with the
http3feature, next to HTTPS. See below.
use std::time::Duration; use zapline::prelude::*; use zapline::tls::TlsConfig; use zapline::RuntimeMode; fn main() -> std::io::Result<()> { App::new() .get("/", || async { "secure" }) .tls(TlsConfig::from_pem_files("cert.pem", "key.pem")?) // feature `tls` .http2(true) // feature `http2` .workers(8) .max_connections(10_000) // per worker .shutdown_timeout(Duration::from_secs(20)) .runtime(RuntimeMode::PerCore) .run("0.0.0.0:8443") }
Custom shutdown and embedding
#![allow(unused)] fn main() { // Stop when a channel fires instead of on signals: let (tx, rx) = tokio::sync::oneshot::channel::<()>(); app.run_with_shutdown("0.0.0.0:8080", async { let _ = rx.await; })?; // Inside your own Tokio runtime: let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?; app.serve(listener).await?; // also stops on SIGINT/SIGTERM app.serve_with_shutdown(listener, std::future::pending()).await?; // no signal handling // Unix domain socket (e.g. behind nginx): app.run_unix("/run/my-app.sock")?; }
ServerConfig
For full control, use App::server_config(..) or App::server_config_mut():
| Field | Default | Purpose |
|---|---|---|
workers | CPU count | Worker threads |
runtime | PerCore (Linux) | PerCore or MultiThread |
header_read_timeout | 30s | Slowloris protection; also closes idle keep-alive connections |
body_read_timeout | 30s | A buffered body (Json, Form, String, ...) that arrives slower gets 408 and the connection closes. Streaming bodies set their own pace |
keep_alive / pipeline_flush | true / false | HTTP/1.1 behavior. pipeline_flush batches pipelined responses (see the limitation below) |
max_connections | unlimited | Per worker. Accepting pauses at the limit. WebSockets keep their slot after the upgrade. |
max_upgraded | none | Per worker. A separate limit for WebSockets; upgrades beyond it get 503. |
max_header_size, max_headers, max_buf_size | hyper defaults | Request head limits |
http2, http2_max_concurrent_streams, http2_keep_alive_interval | true (feature on) | HTTP/2 tuning |
shutdown_timeout | 30s | Drain deadline |
drain_grace | 1s | How long an idle connection may still send a request once draining starts; its response says connection: close (why) |
tls_handshake_timeout | 10s | Abandon slow handshakes |
backlog | 4096 | Listen backlog |
banner | true | Print the startup line |
Invalid combinations are rejected at startup with ErrorKind::InvalidInput.
TLS details
TlsConfig::from_pem_files, from_pem or from_rustls(Arc<rustls::ServerConfig>) set up TLS. For SNI, mTLS or custom ciphers, build the rustls config yourself; zapline::tls::rustls is re-exported. The handshake runs in the connection task with a timeout, so slow clients never block accepting. Handlers can see ConnInfo::secure == true.
WebSockets and the server
An upgraded WebSocket stays a connection as far as the server is concerned:
- It keeps counting against
max_connections. The socket takes over the slot of the HTTP connection it came from, with no gap in between, so a new connection can't slip in when hyper hands the socket over. At the limit, an upgrade is never refused: it doesn't need a new slot. - Or against its own limit. Set
max_upgradedto count WebSockets separately. The HTTP connection's slot is then freed at the upgrade, and an upgrade beyondmax_upgradedis answered with503(ws_too_many_connections,Retry-After: 1) instead of101, so your callback never runs. - Over HTTP/2 (RFC 8441) a socket is one stream on a connection that stays open and keeps its
max_connectionsslot until its last stream ends, so the socket takes no slot of its own there: one client cannot fillmax_connectionswith streams on a single connection (http2_max_concurrent_streamsbounds the streams per connection). Withmax_upgradedset, it counts against that like any other socket, with the same503when it is full. - Graceful shutdown waits for it. When the drain starts, each socket sends a close frame with code
1001(close_code::AWAY, "server shutting down") the next time your callback reads from or writes to it. Arecvloop then sees the close handshake and ends; asendafterwards fails withws_closed. The server waits for the callbacks to return, up toshutdown_timeout, and then drops the ones still running (the socket closes). Upgrades that arrive during the drain get503(ws_shutting_down).
use std::time::Duration; use zapline::prelude::*; use zapline::ws::{WebSocket, WebSocketUpgrade}; async fn feed(ws: WebSocketUpgrade) -> Response { ws.on_upgrade(|mut socket: WebSocket| async move { // Ends on shutdown: recv() completes the 1001 close handshake. while let Some(Ok(msg)) = socket.recv().await { if msg.is_text() && socket.send(msg).await.is_err() { break; } } // Cleanup here still runs before the server returns. }) } fn main() -> std::io::Result<()> { let mut app = App::new() .get("/feed", feed) .max_connections(2_000) // HTTP connections per worker .shutdown_timeout(Duration::from_secs(10)); app.server_config_mut().max_upgraded = Some(50_000); // WebSockets per worker app.run("0.0.0.0:8080") }
A callback that waits on something other than the socket (a channel, a timer) sees the shutdown when it next touches the socket. Put socket.recv() in your select! so the close frame goes out right away. None of this costs plain HTTP connections anything: the accounting is set up in the handler, only for upgrades.
HTTP/3 (experimental)
Experimental. The
http3feature is not part offull, and its API may change in any release. It is built on the pre-1.0h3crate and quinn.
With the http3 feature, the server also listens on a UDP port and serves the same router over QUIC. Routes, extractors, middleware, state and error handling are shared with HTTP/1.1 and HTTP/2; nothing in your handlers changes. HTTP/3 needs TLS and reuses the certificate from App::tls.
use zapline::prelude::*; use zapline::http3::Http3Config; use zapline::tls::TlsConfig; fn main() -> std::io::Result<()> { App::new() .get("/", || async { "hello over TCP and QUIC" }) .tls(TlsConfig::from_pem_files("cert.pem", "key.pem")?) .http3(Http3Config::new()) // UDP 8443, next to TCP 8443 .run("0.0.0.0:8443") }
Browsers don't try HTTP/3 first. They learn about it from the Alt-Svc header, and switch to QUIC on their next connection. zapline adds Alt-Svc: h3=":8443"; ma=86400 to every HTTP/1.1 and HTTP/2 response (including errors and responses from middleware) unless the handler set its own. The header comes from a middleware that is installed only when HTTP/3 is configured, so apps without it pay nothing per request.
Http3Config method | Default | Purpose |
|---|---|---|
port(u16) | the TCP port | UDP port to listen on |
advertise(bool) | true | Send Alt-Svc on HTTP/1.1 and HTTP/2 responses. Turn it off when a CDN advertises for you. |
alt_svc_max_age(Duration) | 24h | The ma parameter: how long clients remember the advertisement |
max_concurrent_streams(u32) | 100 | Concurrent requests per QUIC connection |
max_idle_timeout(Duration) | 30s | Close connections with no traffic for this long |
How it behaves:
- Requests have
version() == Version::HTTP_3andConnInfo::secure == true. Request bodies stream, so the body limit,Json<T>andBodyStreamwork as over TCP. Responses stream too (SSE,StreamBody, trailers), andContent-Lengthis set when the body size is known. max_header_sizelimits the HTTP/3 header section,header_read_timeoutbounds how long a request stream may take to send its headers,tls_handshake_timeoutbounds the QUIC handshake, andmax_connectionscaps QUIC connections separately from TCP ones. QUIC has no kernel backlog, so connections over the limit are refused and clients fall back to TCP.- Graceful shutdown sends
GOAWAY: requests in flight finish, new ones are refused, and the UDP socket stops accepting connections. Aftershutdown_timeoutthe remaining connections are closed. - With
App::run, HTTP/3 runs on its own runtime (workersthreads, namedzapline-h3). WithApp::serve, it runs on your runtime and binds UDP on the listener's address.App::run_unixwith HTTP/3 fails withInvalidInput. - The startup line shows it:
zapline listening on https://0.0.0.0:8443 (..., HTTP/1.1 + HTTP/2, TLS + HTTP/3 on UDP 0.0.0.0:8443).
Open UDP on the port in your firewall or load balancer. Many cloud load balancers only pass TCP; HTTP/3 then has to terminate at the edge, and you can use advertise(false).
Not supported yet: WebSockets over HTTP/3 (RFC 9220; they work over HTTP/1.1 and HTTP/2), and 0-RTT (early data can be replayed, so it stays off).
Known limitations
- With
pipeline_flushon (off by default; turn it on for heavily pipelined clients such as benchmark tools), hyper 1.12 closes the connection on a malformed or oversized request head without sending the 400/414/431 response. Responses to earlier pipelined requests that were still buffered on that connection are lost with it. Keep it off if you need those responses. zapline can't send them itself: hyper drops its write buffer, and only it knows what was in it (see the upstream issue draft). The testhyper_drops_automatic_error_responses_with_pipeline_flushfails once a hyper release fixes it. - Individual HTTP/2 streams aren't tracked by the drain: an HTTP/2 connection gets a GOAWAY and closes when its streams finish. With
App::runthe rest end when the workers stop. - There is no write timeout: a client that stops reading a response holds its connection (and the unsent part of a buffered response) until it disconnects. Bound how many such clients can pile up with
max_connections, or put a reverse proxy in front for internet-facing apps that serve large responses.
Deployment
Build
# Cargo.toml of your app
[profile.release]
lto = "fat"
codegen-units = 1
panic = "abort" # note: CatchPanic cannot catch panics with abort
cargo build --release
Consider mimalloc as the global allocator for throughput-heavy services.
One binary, one command
A zapline app can be a single executable: templates, static files, migrations,
translations and config compiled in. zapline ship puts it on a Linux server
and swaps it in without dropping a request.
Compile the directories in: zapline::embed!
App::new()
.views(zapline::embed!("templates"))
.assets("/static", zapline::embed!("static"))
.database(Database::from_env().migrations(zapline::embed!("migrations")))
.i18n(I18n::load(zapline::embed!("locales"))?)
embed!("dir") reads the directory (relative to the crate's Cargo.toml)
at compile time and gives a Dir.
Everything that takes a directory takes a Dir: App::views, App::assets,
ServeDir::new, Database::migrations, I18n::load and
Config::builder().standard_in(..) (for config/).
- Release builds serve from memory. Fingerprinted asset URLs, ETags,
ranges and precompressed
.gz/.brfiles work as they do from disk; the hashes are computed at compile time, so startup reads nothing. Migrations have the same checksums as the files, so a database migrated from disk accepts the embedded copy. - Debug builds (and
zapline dev --release) read the real directory while it exists, so editing a template or a stylesheet still reloads without a rebuild.Dir::in_memory()uses the compiled copy in debug too (for tests).
Cargo rebuilds when an embedded file changes, but it doesn't watch for files
that are added. The fullstack template has a build.rs for that; in your
own project, add one:
// build.rs
fn main() {
if std::env::var("PROFILE").as_deref() == Ok("release") {
for dir in ["templates", "static", "migrations"] {
println!("cargo:rerun-if-changed={dir}");
}
} else {
println!("cargo:rerun-if-changed=build.rs");
}
}
(zapline ship also sets ZAPLINE_EMBED_STAMP, which forces the re-read.)
Deploy: zapline ship
zapline ship deploy@203.0.113.7 --dry-run # every command and file, nothing runs
zapline ship deploy@203.0.113.7 # build, upload, swap
zapline ship deploy@203.0.113.7 --rollback # the previous release, the same way
The server needs systemd and a login that is root or can sudo. Only the
system ssh and scp are used, so keys, agents, ~/.ssh/config hosts and
known hosts work as in your terminal. One ssh connection is shared by
the whole run.
What one run does:
-
ssh host uname -smpicks the build: a plaincargo build --releasewhen this machine has the same OS and CPU, otherwise an error that says what to use.--staticbuilds for<cpu>-unknown-linux-musl, which runs on any Linux. It usesmusl-gcc(apt install musl-tools), a<cpu>-linux-musl-gcccross compiler, orcargo zigbuild, whichever is installed, and says what to install when none is.--target <triple>picks the target yourself. -
With
styles/app.css, Tailwind builds the minifiedstatic/app.cssfirst. -
scpuploads the binary, its SHA-256, a systemd unit, the settings file and the install script to a private directory in/tmp. -
The script installs and swaps:
First run only Every run A system user named after the app The binary in /opt/<app>/releases/<id>/, checked against its SHA-256/etc/<app>/envwithPORT,APP_ENV=production, the database URL (SQLite in/var/lib/<app>/), and a generated value for each*_SECRET/*_KEYin.env.example--env KEY=VALUEsettings merged into/etc/<app>/envnet.ipv4.tcp_migrate_req=1(see below)/etc/systemd/system/<app>@.servicerewrittenIt then runs
systemctl start <app>@<id>next to the running release. Both bind the same port (SO_REUSEPORT).systemctl startreturns only once the new process has answered its own health check (/health/readywith theHealthplugin, otherwise/;--healthpicks another path) and saidREADY=1. The script checks the port with curl, points/opt/<app>/currentat the new release, enables it for boot, and stops the old one. The old release drains: it finishes its requests and closes its connections. Only the newest three releases are kept (--keep N).
Any failure before the old release stops leaves it serving. This covers a build error, a corrupted upload, a release that crashes on startup, a failing health check or a port that doesn't answer. The new release and its unit change are undone, and the script prints the new release's last log lines. If the new release dies right after the swap, the old one is started again.
On the server:
journalctl -u 'myapp@*' -f # logs
sudoedit /etc/myapp/env # settings; ship again (or restart) to apply
systemctl status 'myapp@*'
ls /opt/myapp/releases # what --rollback can go back to
Why no request is dropped
- The new process is ready before it takes traffic. With
ZAPLINE_READY_PATHset, the server requests that path from itself, in-process, before it binds the port. It retries until the answer is below 400, for up toZAPLINE_READY_TIMEOUTseconds (default 30; the unit sets 60). A 404 fails at once. WithNOTIFY_SOCKETset (systemdType=notify), it sendsREADY=1once it is listening andSTOPPING=1when it starts to drain. - Two processes share the port.
ZAPLINE_REUSE_PORT=1makes every listener useSO_REUSEPORT. The default thread-per-core runtime always does on Linux. - Queued connections move over. When a listener closes, Linux drops the
connections still waiting in its accept queue. With
net.ipv4.tcp_migrate_req=1(Linux 5.14+, whichzapline shipturns on), it hands them to the other listener on the port instead. - Keep-alive connections end cleanly. For
ServerConfig::drain_grace(default 1s) after draining starts, a connection may still send its next request. The response saysconnection: close, so the client opens its next connection to the new release. Closing an idle keep-alive connection right away would reset a request that is already on its way. Connections still idle when the grace ends are closed.
On a test server, 8 clients (half of them keeping connections alive) sent
about 150,000 requests to a fullstack app through a swap, and none failed.
The same environment variables work under any supervisor that can start a second instance before stopping the first.
Container image
Projects generated by zapline new (api, full and fullstack templates) include a two-stage Dockerfile. The first stage compiles your dependencies in their own cached layer, then your code. The runtime image is debian:bookworm-slim with CA certificates, running as a non-root user. The fullstack binary has templates/, static/ and migrations/ compiled in (embed!), so the image holds just the binary, and it keeps the SQLite file in /home/app/data, which is meant to be a volume.
The app listens on ADDR if set, otherwise on 0.0.0.0:$PORT (the image sets PORT=8080). Hosts that choose the port, such as Railway and Render, override PORT.
Hosting configs: zapline g deploy
zapline g deploy fly # fly.toml
zapline g deploy railway # railway.json
zapline g deploy render # render.yaml (a Blueprint)
zapline g deploy compose # compose.yaml: any server with Docker
Each target builds the project's Dockerfile, and the CLI adds one if the project has none. The config follows what the app uses:
| The project has | The config gets |
|---|---|
A SQLite database (the fullstack default) | A volume at /home/app/data (Fly mount, Render disk, Compose volume) |
zapline's postgres feature | A Postgres service with DATABASE_URL wired to it (Compose, Render) |
zapline's redis feature | A Redis service with REDIS_URL (Compose) |
The Health plugin | Health checks on /health/ready |
Settings in .env.example (SESSION_SECRET, SMTP_URL, ...) | Entries that read them from the environment; on Render, *_SECRET and *_KEY are generated |
After writing the file, the CLI prints what is left to do on that host, such as fly volumes create data and fly secrets set SESSION_SECRET=$(openssl rand -base64 48) .... Nothing is deployed and no account is touched until you run those commands.
With Compose, put the settings in a .env file next to compose.yaml. It is in .dockerignore, so it never ends up in the image:
echo "SESSION_SECRET=$(openssl rand -base64 48)" > .env
docker compose up --build -d # http://localhost:8080
Kubernetes
zapline handles SIGTERM gracefully. It stops accepting new connections, lets in-flight requests finish (up to shutdown_timeout, default 30s), and then runs shutdown hooks. Point probes at the health plugin:
livenessProbe:
httpGet: { path: /health/live, port: 8080 }
readinessProbe:
httpGet: { path: /health/ready, port: 8080 }
terminationGracePeriodSeconds: 35 # > shutdown_timeout
Scrape /metrics with Prometheus. Use JSON logs (Telemetry::new().json().init()) for log aggregation.
Behind a reverse proxy
- Rate limiting by IP must trust only your proxy:
RateLimit::per_ip(..).trust_forwarded_for(["10.0.0.0/8"]). - Terminate TLS at the proxy, or in zapline with
App::tls(TlsConfig::from_pem_files(cert, key)?). - On Unix you can listen on a domain socket:
App::run_unix("/run/app.sock").
Configuration in production
Set APP_ENV=production to layer config/production.toml over config/default.toml. Inject secrets through environment variables such as APP__DATABASE__URL=.... Wrap them in Secret<String> so they never show up in logs.
Running several instances (Redis)
One zapline process goes a long way (see Performance), but for zero-downtime deploys or more traffic you will run two or more instances behind a load balancer. State kept in memory then splits between them: a cache entry, a live update or a rate-limit bucket on one instance is invisible to the others.
What already works across instances, because it lives in the database:
| Piece | Shared through |
|---|---|
Sessions with App::database_sessions | The zapline_sessions table |
| Durable jobs | The zapline_jobs table (FOR UPDATE SKIP LOCKED on Postgres, so instances share one queue) |
Uploaded files with Storage::s3 | The bucket |
For the rest, the redis feature adds a small built-in Redis client (no extra dependency; redis-tls adds rediss://) and Redis-backed versions of the in-memory pieces:
#![allow(unused)] fn main() { use zapline::cache::Cache; use zapline::live::Live; use zapline::middleware::RateLimit; use zapline::redis::{Redis, RedisStore}; let redis = Redis::from_env()?; // REDIS_URL=redis://:password@host:6379/0 App::new() .cache(Cache::redis(redis.clone())) // was Cache::memory() .live(Live::new().redis(redis.clone())) // updates reach every instance .wrap(RateLimit::per_ip(100, Duration::from_secs(60)).redis(redis.clone())) .wrap(SessionLayer::new(RedisStore::new(redis.clone()))) // or database_sessions .redis(redis) // `redis: Redis` in handlers }
| Piece | With Redis | If Redis is down |
|---|---|---|
Cache::redis | Entries under zapline:cache:, expiring with PX. delete_prefix and clear use SCAN + UNLINK, so they never block Redis. | Reads miss and writes are skipped, with a warning in the logs: pages get slower, not broken. |
Live::new().redis(..) | Each update is delivered to this instance's subscribers right away and published on zapline:live:{topic}; every other instance delivers it to its own. Order is kept per publisher. | Updates stay on the instance that published them; the listener reconnects with backoff. |
RateLimit::...redis(..) | The same GCRA as in memory, run as one Lua script per request on Redis's clock, so instances with skewed clocks agree. | Requests are let through, with a warning. |
RedisStore | Sessions under zapline:session:, expiring with the session. | Requests that need the session fail with 503. |
Redis::cmd runs any command, for whatever else you need to share:
#![allow(unused)] fn main() { #[post("/visits")] async fn visit(redis: Redis) -> Result<String> { let n = redis.cmd(["INCR", "visits"]).await?.int().unwrap_or(0); Ok(n.to_string()) } }
A Redis error reply becomes an error (500), and an unreachable Redis a 503. Idle connections are kept for reuse (up to 16), and a command on one that the server closed is retried once on a fresh connection.
The client speaks RESP2 with AUTH (password, or user and password) and a database number from the URL: over TCP for redis://, and over TLS for rediss:// (below).
Redis over TLS
Managed Redis services (AWS ElastiCache and MemoryDB, Azure Cache, Upstash, Redis Cloud, Heroku, DigitalOcean...) usually hand out a rediss:// URL. Turn on the redis-tls feature and use it as is:
zapline = { version = "0.1", features = ["redis-tls"] } # includes `redis`
REDIS_URL=rediss://default:password@my-cache.example.com:6380/0
Everything above (the cache, live updates, rate limits, sessions, Redis::cmd) then runs over TLS 1.2 or 1.3 with rustls, and nothing else changes: connections are still pooled, and one the server closed is replaced by a fresh handshake. The server's certificate is checked against the Mozilla root store (compiled in, so it works in scratch and distroless images) for the URL's host, which is also sent as SNI.
For a self-hosted Redis with its own CA, or a certificate for a name other than the host you connect to, add parameters to the URL (values percent-encoded):
| Parameter | Meaning |
|---|---|
ca_file | A PEM file of CA certificates to trust instead of the Mozilla roots: your private CA, or a self-signed server certificate |
sni | The name sent as SNI and checked on the certificate, when it differs from the host: connecting by IP, through a tunnel or port-forward, or to a load balancer |
cert_file, key_file | A client certificate chain and its key (PKCS#8, PKCS#1 or SEC1 PEM), for servers that require mutual TLS (tls-auth-clients yes) |
REDIS_URL='rediss://:s3cret@10.0.0.5:6380/0?ca_file=/etc/redis/ca.pem&sni=redis.internal'
REDIS_URL='rediss://:s3cret@redis.internal:6380?ca_file=/run/secrets/ca.pem&cert_file=/run/secrets/client.pem&key_file=/run/secrets/client.key'
Files are read when the client is made (Redis::new / Redis::from_env), so a missing or invalid one stops the app at startup rather than on the first request. When the PEM lives in a secret rather than a file, pass it in memory instead:
#![allow(unused)] fn main() { let secret = |name: &str| std::env::var(name).map_err(|_| Error::internal(format!("{name} is not set"))); let redis = Redis::from_env()? // REDIS_URL=rediss://... .with_ca_pem(secret("REDIS_CA_PEM")?)? .with_client_cert_pem(secret("REDIS_CLIENT_CERT")?, secret("REDIS_CLIENT_KEY")?)?; }
There is deliberately no switch to skip certificate verification: for a self-signed server, trust its certificate with ca_file. A handshake that fails (an unknown CA, a certificate for another name, a missing client certificate) is a 503 whose message says why, for example TLS handshake with Redis at 10.0.0.5:6380 failed: invalid peer certificate: UnknownIssuer.
Without the redis-tls feature, a rediss:// URL is refused at startup with an error that names the feature, and the redis feature alone pulls in no TLS code.
Without Redis
If you'd rather not run Redis: keep sessions and jobs in the database as above, use Cache::memory() per instance (each instance warms its own cache; fine for data that may be a little stale), and accept that per-instance rate limits are limit × instances in total. Live updates are the one piece that needs a shared channel to reach visitors connected to other instances.
Performance
zapline is designed so that the fastest configuration is also the default one.
Benchmarks
./bench/run.sh runs identical endpoints written in zapline, axum and actix-web. The script pins servers and wrk to separate CPU sets, warms up, and reports the best of N runs. See bench/README.md for the methodology. Results from the reference machine (2 server cores) are in the project README.
Always benchmark on your own hardware, with the client and server on separate machines if possible. Numbers from a shared VM are only useful for relative comparison.
Database tests (TechEmpower)
bench/tfb/ is a complete TechEmpower Framework Benchmarks implementation: plaintext, JSON, single query, multiple queries, fortunes, updates and cached queries against Postgres with TFB's schema, plus the benchmark_config.json, Dockerfile and a script that prepares the submission. ./bench/tfb/run.sh starts a private Postgres cluster with TFB's settings, checks every endpoint with verify.py (a standalone copy of TFB's checks: headers, content types, fortunes ordering and escaping, query counts clamped to 1..500, and the number of queries per request counted with pg_stat_statements), then loads each test with wrk the way TFB does. See bench/tfb/README.md.
One local run, on a 4-vCPU VM (2 app workers, Postgres on 1 CPU, wrk on 1 CPU, 512 connections, 15 s per test), in requests per second:
| test | per-worker pool | per-worker pool + ping | App::database pool |
|---|---|---|---|
| plaintext (16 pipelined) | 859,217 | ||
| json | 87,065 | ||
| db | 17,276 | 13,041 | 12,602 |
| queries (20) | 1,043 | 977 | 960 |
| fortunes | 17,032 | 14,089 | 14,971 |
| updates (20) | 808 | 752 | 845 |
| cached queries (100) | 18,445 |
Caveats: the app, Postgres and wrk share one machine, other jobs were running on it (the load average rose from about 6 to 30 during the run), and a single wrk thread limits the plaintext and JSON rows. A second run the same day gave 19,243 vs 12,541 on db and 18,907 vs 13,360 on fortunes for the first and last columns. These are not comparable with published TFB rounds; they are there to compare the columns with each other.
The columns are the pool modes bench/tfb can run in:
- Per-worker pool: each worker thread lazily opens its own pool (16 connections) and checks connections out without a ping. With thread-per-core runtimes a sqlx connection's socket is driven by the runtime that opened it; a per-worker pool keeps a request and its queries on one core.
- Per-worker pool + ping: the same, with sqlx's
test_before_acquire(a round trip to Postgres on every checkout) back on. App::databasepool: the canonicalApp::database+Dbpath: one pool of the same total size, opened on the startup runtime, with sqlx's defaults.
On this machine nearly all of the difference comes from the ping, not from thread affinity: the second and third columns are within noise of each other, and with Postgres on a single CPU the 20-query tests are bound by the database whatever the pool does. On machines with more cores per database the affinity effect may matter more; measure before relying on it.
Compile times
./bench/compile/run.sh scaffolds apps with zapline new (the minimal and api templates) and builds them next to the same apps written with axum 0.8 and actix-web 4 (for api: the same CRUD routes with validation, an OpenAPI document with a docs UI via utoipa + utoipa-scalar, and security headers). See bench/compile/README.md for exactly what is measured. One run on the same 4-vCPU VM (dev profile, default linker; cold columns best of 3, edit columns median of 3):
| app | crates | cold check | cold build | rebuild after editing a handler | check after editing a handler | debug binary |
|---|---|---|---|---|---|---|
| zapline, minimal | 108 | 19.0 s | 27.2 s | 0.6 s | 0.3 s | 60 MB |
| axum, minimal | 48 | 10.9 s | 14.6 s | 1.4 s | 0.2 s | 22 MB |
| actix-web, minimal | 146 | 25.7 s | 34.5 s | 1.8 s | 0.3 s | 78 MB |
| zapline, api | 108 | 16.7 s | 26.1 s | 1.3 s | 0.5 s | 68 MB |
| axum, api | 95 | 16.7 s | 23.5 s | 2.5 s | 0.4 s | 50 MB |
| actix-web, api | 159 | 31.2 s | 40.2 s | 2.9 s | 0.5 s | 95 MB |
What this run shows: a zapline app costs the same to build cold whatever its size, because its default features (OpenAPI, validation, config, tracing) compile in from the start; that makes the minimal app about twice as slow to build cold as bare axum, while the API app is within 3 s of the axum equivalent and well ahead of actix-web. Rebuilding after editing a handler was the fastest of the three at both sizes; checking after an edit was within 0.1 s of the others. The VM was shared with other jobs (load average 2 to 4), so differences of a second or two in the cold columns are within noise; see bench/compile/results.md for the machine details.
What makes it fast
- Thread-per-core with
SO_REUSEPORT. Each worker thread runs its own single-threaded Tokio runtime and its own listening socket, and the kernel load-balances new connections across them. A connection never migrates between threads, and there's no work-stealing scheduler on the hot path. - Core-local reference counts. Each worker holds its own
Archandles to app data. Per-request clones only touch the current core's cache lines. - Zero-allocation routing. Lookup is a radix tree followed by a fixed-slot method table. Route parameters are byte ranges into the path, inline for up to 4. The matched route is a
&'staticpointer. - One allocation per routed request. The only allocation is the boxed handler future. A 404 or 405 allocates nothing.
- Pipelining. Responses to pipelined HTTP/1.1 requests are flushed together.
- Build-time work. OpenAPI specs, metrics series and security header values are computed once at startup, not on each request.
Tuning
| Knob | Default | Notes |
|---|---|---|
App::workers(n) | CPU count | One runtime per core |
ServerConfig::runtime | PerCore on Linux and FreeBSD | Use MultiThread when handlers share heavy I/O resources and work-stealing helps |
ServerConfig::header_read_timeout | 30s | Slowloris protection. It costs a timer per request. None disables it. |
App::body_limit | 2 MiB | |
| Allocator | system | mimalloc gives a noticeable boost and is used in the benchmarks |
| Release profile | — | lto = "fat", codegen-units = 1, panic = "abort" |
Guidelines for app code
- Wrap shared state in
Arcand avoid global locks on the request path. Prefer sharded or lock-free structures. - Use
wrap_routeinstead ofwrapfor middleware that only needs matched routes. That way it doesn't run on 404s. - Return
Bytesor&'static strfor static payloads. Both are zero-copy. - Run blocking work with
tokio::task::spawn_blocking. - For database-heavy endpoints, measure the pool.
App::databasekeeps sqlx's defaults, including a ping on every connection checkout; in the TechEmpower run above, a pool without it served about 35% more single-query requests.bench/tfb/src/main.rsshows a per-worker pool built from sqlx'sPoolOptionsbehind a small extractor.
Reliability
How zapline is tested beyond its unit and integration tests, what those tests found, and where it is still weaker than frameworks with years of production use. Everything here can be re-run from the repository.
How it is tested
| Check | What it does | Command |
|---|---|---|
| Test suite | About 1,900 tests: in-process and real-socket integration tests for every module, CLI and code generator | ./scripts/check.sh |
| Chaos suite | Starts a real server and attacks it over real sockets (scenarios below) | ./bench/chaos/run.sh |
| Soak | Mixed keep-alive load for as long as you like, sampling memory to find leaks | ./bench/chaos/run.sh soak soak=3600 |
| Fuzzing | Coverage-guided (libFuzzer) arbitrary requests through path params, query, form, JSON, multipart, cookies (plain, signed, private), sessions, CSRF, JWT, i18n, static files, compression, CORS and path normalization. Any panic or 5xx fails | cargo +nightly fuzz run request |
| Real deployment | zapline ship to a systemd server over SSH, under load | see below |
| Supply chain | RustSec advisories, licenses, crate sources | cargo deny check, cargo audit |
No unsafe | #![forbid(unsafe_code)] in every crate: the compiler rejects any unsafe block | built in |
Chaos scenarios
Each scenario starts a fresh server, attacks it, then checks that the process is alive, still answers a normal request quickly, and gave back its file descriptors.
| Scenario | Result |
|---|---|
| 5,000 connections of random bytes | No 5xx, server healthy |
24 malformed and smuggling-style requests (bad methods, bare CR, obs-fold, conflicting Content-Length, bad chunk sizes, 100 KB URIs, 1 MB headers, 1,000 headers, invalid UTF-8, 100,000-deep JSON, broken multipart, ...) | Each gets the right 4xx (400, 413, 414, 415, 422, 431) |
| Slowloris: 1,000 connections sending one header byte at a time | All cut at header_read_timeout; other clients answered in under 1 ms meanwhile |
| 500 idle keep-alive connections | All closed by the server at the timeout |
| 200 request bodies sent at 2 bytes/s | 408 and closed at body_read_timeout |
| 60,000 connections dropped at every stage (mid-head, mid-body, mid-response, mid-upgrade, with RST) | Memory flat between rounds, no descriptor leak |
3 MiB bodies (with Content-Length, chunked, multipart), 150,000 form fields | 413 before the body is read; huge forms handled |
| 2,000 panicking handlers | Server stays up (500s with CatchPanic) |
| 600 connections against a 256 file-descriptor limit | Accept loop backs off (1% CPU), recovers when descriptors free up |
| HTTP/2 rapid reset (CVE-2023-44487), PING flood, SETTINGS flood | GOAWAY after a few thousand resets, memory flat |
| WebSocket frame claiming 2^62 bytes; 2,000 open sockets; 50,000 echoes | Closed at once; 23 MiB for 2,000 sockets |
| Soak: 128 keep-alive connections, mixed routes | 10 minutes: 130 million requests (216,000/s), 0 errors, p99 2.5 ms, memory flat at 9 MiB |
Real deployment
zapline ship was run against a real systemd server (Ubuntu 24.04, systemd
255, a sudo login over SSH) while 16 clients sent requests continuously:
- A new release swapped in during 217,224 requests with 0 failures (responses switched from v1 to v2 mid-run).
--rollbackunder the same load: 0 failures.- A release that panics at startup: the deploy stopped with the release's log lines, and the old release kept serving all 154,434 requests.
- Settings edited on the server and the generated secrets survived the next deploy.
The dev loop was checked in a real browser (Chromium): a template edit reloads the page in about 0.4 s without a rebuild, a CSS edit swaps the stylesheet in about 0.1 s keeping page state, and a compile error shows an overlay with the file, line and compiler output that clears itself once the code is fixed.
What the audit fixed
- Slow request bodies held connections forever. New
ServerConfig::body_read_timeout(default 30s) answers408. - Malformed requests got a dropped connection instead of a 4xx because
pipeline_flushwas on by default (a hyper bug, see upstream issues). It is now off by default; only the benchmarks turn it on. - WebSockets used 128 KiB each (tungstenite's read buffer). They now start at 8 KiB: 2,000 sockets went from 258 MiB to 23 MiB.
zapline shipregenerated the server's settings on every deploy when logged in with sudo: it tested for the settings file without root, could not see it, and wrote a new one (new secrets, default port). Fixed, with a test that every access to the root-only directories goes throughsudo.
Where zapline is weaker than mature frameworks
Honest gaps, so you can decide where it fits today:
- Production mileage. axum and actix-web have run in production for years at large scale. zapline's tests are thorough, but nothing replaces that. Run it behind a reverse proxy or load balancer for internet-facing apps until you have your own confidence.
- No write timeout. A client that stops reading a response keeps its
connection, and the unsent part of the response, until it disconnects (200
such clients held about 800 MiB of 4 MiB responses). axum has the same
behavior; a reverse proxy,
max_connections, or streaming large responses bounds it. - Streaming bodies set their own pace.
body_read_timeoutcovers buffered bodies;BodyStreamandMultiparthandlers should bound their own uploads. - Young code, small team. Bugs like the
zapline shipsettings one are what a young project still has. The chaos suite and fuzzer exist so the next one is found by a test, not by a user. - HTTP/3 is experimental, and HTTP parsing is hyper's (well fuzzed and widely deployed, which is a strength).
Platform support
zapline runs on Linux, macOS and Windows. CI builds, lints and runs the full test suite on all three, and checks that projects generated by zapline new build and pass their tests on each.
Support matrix
| Linux | macOS | Windows | |
|---|---|---|---|
| Framework, all Cargo features | ✅ | ✅ | ✅ |
| Default server runtime | thread-per-core (SO_REUSEPORT) | multi-threaded (work-stealing) | multi-threaded (work-stealing) |
| HTTP/1.1, HTTP/2, TLS | ✅ | ✅ | ✅ |
| Graceful shutdown on | SIGINT, SIGTERM | SIGINT, SIGTERM | Ctrl-C, Ctrl-Break, console close, system shutdown / docker stop |
App::run_unix (Unix domain sockets) | ✅ | ✅ | — (not available) |
| Static files | ✅ | ✅ | ✅ plus Windows-specific path protection (below) |
zapline CLI | ✅ | ✅ | ✅ |
zapline dev restart | SIGTERM, then kill after 5s | SIGTERM, then kill after 5s | immediate stop |
zapline bench | wrk or oha | wrk or oha | oha |
bench/run.sh comparison | ✅ | — (uses Linux taskset) | — |
| Local checks | ./scripts/check.sh | ./scripts/check.sh | .\scripts\check.ps1 |
Performance: the thread-per-core runtime depends on Linux's SO_REUSEPORT load balancing. On macOS and Windows, RuntimeMode::PerCore falls back to the work-stealing runtime automatically. That is fast and fully supported, but publish benchmark numbers from Linux.
Setting up on Windows
- Install the Visual Studio Build Tools with the "Desktop development with C++" workload. Rust uses its linker, and two dependencies (
ringfor TLS,zstdfor compression) compile C code.rustup-init.exeoffers to install it for you. - Install Rust from https://rustup.rs (
rustup-init.exe). The defaultx86_64-pc-windows-msvctoolchain is the one CI tests. - In PowerShell:
git clone https://github.com/samsamurai301/zapline.git
cd zapline
.\scripts\check.ps1 # fmt, clippy, tests, feature matrix, examples, docs
cargo install --path crates\zapline-cli # installs zapline.exe and cargo-zapline.exe
zapline new my-api --zapline-path .\crates\zapline
cd my-api; zapline dev
If PowerShell blocks scripts, run powershell -ExecutionPolicy Bypass -File .\scripts\check.ps1.
For load testing, install oha with cargo install oha. zapline bench uses it automatically when wrk isn't available.
Windows-specific behavior
- Ports: zapline doesn't set
SO_REUSEADDRon Windows. There, unlike on Unix, the option would let another process bind the same port while your server is listening. - Shutdown: Windows force-terminates a process about 5 seconds after a console-close event and about 20 seconds after a shutdown event. If you rely on draining, keep
shutdown_timeoutbelow that, for example with.shutdown_timeout(Duration::from_secs(4)). - Static files: besides the protections on every platform (
.., encoded separators, symlink escapes),ServeDirrejects:- drive and stream syntax (
C:,file:stream) - DOS device names (
CON,NUL,COM1,lpt1.txt, ...) - names ending in a dot or space, which Win32 silently strips (
secret.txt.would opensecret.txt)
- drive and stream syntax (
- Line endings:
.gitattributeskeeps every file LF on checkout, so templates, generated code and byte-exact tests behave the same as on Unix. Editors on Windows handle LF fine.
Setting up on macOS
Install the Xcode command-line tools (xcode-select --install) and Rust from https://rustup.rs. Then follow the same steps as on Linux. brew install wrk (or cargo install oha) gives you a load generator.
Deploying
Any of the three platforms works in production. For the highest throughput, deploy on Linux, for example in the slim container image that zapline new generates. That's where the thread-per-core runtime applies.
Feature flags
zapline follows "pay only for what you use": anything that pulls in a dependency sits behind a Cargo feature, and a feature you don't use adds no code to your binary and no work to a request.
[dependencies]
zapline = "0.1" # the default features
zapline = { version = "0.1", features = ["ws", "fs"] } # default features plus WebSockets and static files
zapline = { version = "0.1", features = ["full"] } # everything
zapline = { version = "0.1", default-features = false, features = ["json", "macros"] } # a minimal build
Or let the CLI edit Cargo.toml:
zapline add ws fs # Added features: ws, fs
zapline add --list # every feature, one line each; * marks the ones you have
When code uses something behind a feature that is off, zapline check and zapline test say which one to add: hint: enable the `ws` feature: zapline add ws (details).
Every feature
The default features are macros, json, query, form, openapi, validate, config and tracing. The full feature enables the defaults plus everything else.
| Feature | Default | Enables | Implies | Dependencies it adds |
|---|---|---|---|---|
macros | yes | The route attribute macros #[get], #[post], #[put], #[patch], #[delete], #[head], #[options] and #[route], with their OpenAPI arguments (Routing, OpenAPI) | zapline-macros (syn, quote, proc-macro2) | |
json | yes | Json<T> as an extractor and a response, structured error details, Ndjson, Sse::json_data, Message::json and Field::json for WebSockets and uploads, and the test client's .json() | serde_json | |
query | yes | The Query<T> extractor | serde_urlencoded | |
form | yes | The Form<T> extractor and the test client's .form() | serde_urlencoded | |
openapi | yes | zapline::openapi: the OpenApi plugin, Swagger UI, Scalar and Redoc, the builder methods, JsonSchema, and typed clients (client_js, codegen) (OpenAPI, Typed frontends) | json | schemars, zapline-codegen |
validate | yes | zapline::validate: Valid<T> and the 422 error format (Validation) | json | validator (with derive) |
config | yes | zapline::config: Config, Secret<T> and App::config (Configuration) | toml | |
tracing | yes | zapline::telemetry: Telemetry, init() and Trace (Observability). With it, framework logs (failed requests, panics, jobs) go through tracing; without it they go to stderr. | tracing, tracing-subscriber (env-filter, fmt, json) | |
http2 | full | HTTP/2 support on the server (Server) | hyper/http2, hyper-util (http2, server-auto) | |
tls | full | zapline::tls: TLS with rustls (Server) | tokio-rustls (ring, tls12) | |
http3 | Experimental. zapline::http3: HTTP/3 over QUIC next to HTTPS, advertised with Alt-Svc (Server) | tls | quinn (rustls-ring), h3, h3-quinn | |
ws | full | zapline::ws: WebSockets (WebSockets) | tokio-tungstenite (handshake) | |
fs | full | zapline::fs: ServeDir, ServeFile and App::static_files (Static files) | mime_guess, tokio-util (io) | |
multipart | full | zapline::multipart: the Multipart extractor (Uploads) | multer | |
compression | full | middleware::Compression and CompressionLevel (Hardening) | async-compression (gzip, brotli, zstd, deflate, zlib), tokio-util | |
jwt | full | JSON Web Tokens: JwtConfig, Claims, Jwt<C>, JwtAuthenticator, Authenticate::jwt() and App::jwt; key rings with JwtKey (rotate, retire) and App::serve_jwks (Authentication) | json | jsonwebtoken (aws-lc backend, PEM keys) |
jwks | full | Jwks: verify tokens with an identity provider's JWK Set, cached with a TTL, refreshed on unknown kid (rate limited), stale keys kept on errors, OpenID discovery (JWKS) | jwt | hyper-rustls (hyper client, rustls) |
cookies | full | zapline::cookies (CookieJar, signed and private jars, Key), zapline::session (SessionLayer, Session) and auth::{Csrf, CsrfToken} (Cookies, sessions & CSRF) | json | cookie (percent-encode, signed, private), getrandom, hmac, sha2 (signed CSRF tokens) |
views | full | zapline::view: Views, App::views, HxRequest, Hx (Views & htmx) | minijinja (loader) | |
password | full | zapline::auth::password: Argon2id hash and verify (Authentication) | argon2, getrandom | |
mail | full | zapline::mail: Mailer, Email, the dev inbox (Email) | lettre (SMTP, rustls) | |
oauth | full | zapline::auth::oauth: OAuth (GitHub, Google, custom), App::oauth, OAuthLogin (Social login) | json | hyper-rustls (hyper client, rustls), sha2, getrandom |
i18n | full | zapline::i18n: I18n (TOML files per language), App::i18n, the Locale extractor, plurals, t(...) in templates (Translations) | toml | |
redis | full | zapline::redis: a built-in Redis client (Redis, App::redis), Cache::redis, Live::redis, RateLimit::redis, RedisStore for sessions (Running several instances) | json | |
redis-tls | full | rediss:// URLs for the Redis client: TLS with rustls, the Mozilla root store or your own CA (?ca_file=, Redis::with_ca_pem), and an SNI override (?sni=) (Running several instances) | redis | tokio-rustls, webpki-roots |
otlp | full | OpenTelemetry trace export over OTLP/HTTP: Telemetry::otlp, or just OTEL_EXPORTER_OTLP_ENDPOINT (Observability) | tracing, json | hyper-rustls (hyper client, rustls) |
tz | full | IANA time zones (CRON_TZ=Europe/Paris) in cron schedules. UTC and fixed offsets work without it. | jiff (system tz database; bundled on Windows) | |
storage | full | zapline::storage: Storage (local directory, memory), signed links, App::storage (File storage) | mime_guess, hmac, sha2, getrandom | |
s3 | Storage::s3 and S3: S3, R2, MinIO and other S3-compatible services (File storage) | storage | hyper-rustls (hyper client, rustls) | |
cache | full | zapline::cache: Cache (get_or_set without stampedes), ResponseCache, ETag (Caching) | json | |
postgres | zapline::db for Postgres: Database, Db, DbCheck, migrations (Databases) | sqlx (postgres, runtime-tokio, migrate, rustls) | ||
sqlite | zapline::db for SQLite, including Database::sqlite_memory() for tests (Databases) | sqlx (sqlite, runtime-tokio, migrate) | ||
chrono | full | zapline::chrono (NaiveDate, DateTime<Utc>) with sqlx, serde and OpenAPI support; extract::datetime for datetime-local inputs (Databases) | chrono | |
uuid | full | zapline::uuid::Uuid with sqlx, serde and OpenAPI support (Databases) | uuid |
With postgres or sqlite (and json), zapline::jobs adds durable jobs stored in the same database. With postgres or sqlite, App::admin adds an admin panel for every table.
| full | | Every feature above except the database backends (pick postgres and/or sqlite) | all | |
openapi, validate, jwt and cookies turn on json as well, because their error bodies, schemas and session data are JSON.
Always included
Some modules have no feature flag because they add no dependencies:
| Module | What it provides |
|---|---|
| Core | Routing, Path, State, Extension, Bytes and String extractors, responses, errors, the test client, startup/shutdown hooks and plugins |
middleware | Logger, Cors, Timeout, DefaultHeaders, CatchPanic, ConcurrencyLimit, NormalizePath, RateLimit, SetRequestId, SecurityHeaders |
auth | BearerToken, BasicAuth, ApiKey, Authenticate, Guard, route policies (JWT and CSRF need their own features) |
di | Dep<T>, Cached<T>, Inject and the provide* methods |
tasks | BackgroundTasks, App::schedule, App::background |
sse, stream | Server-Sent Events, StreamBody and BodyStream (Ndjson needs json) |
metrics, health | The Prometheus Metrics plugin and the Health plugin |
Choosing features
- A typical JSON API: the defaults. Add
jwtandcompressionwhen you need them. - A web app with server-rendered pages:
cookies(sessions, CSRF) andfs(assets), plusmultipartfor uploads. - Realtime features:
ws. Server-Sent Events need no feature. - A slim microservice:
default-features = false, features = ["json", "macros"]and addconfigortracingif you use them. Types and macros behind a missing feature are not compiled, so the compiler tells you when you reach for one, andzapline checknames the feature to add. - Everything while exploring:
full. The examples in the repository (cargo run -p zapline-examples --example openapi) are built with it.
Features are additive and unify across your dependency tree, so one crate enabling full enables it for all. The same applies to the macros: #[get(..)] works without openapi, it just doesn't document anything.
Put #[model] on request and response types and zapline is the only dependency you need: it derives Serialize, Deserialize, JsonSchema (with openapi) and Validate (with validate) through zapline's own re-exports. Writing the derives yourself still works; then add the crates they expand to:
serde = { version = "1", features = ["derive"] } # #[derive(Serialize, Deserialize)]
schemars = "1" # #[derive(JsonSchema)]
validator = { version = "0.21", features = ["derive"] } # #[derive(Validate)]
tokio = { version = "1", features = ["macros", "rt"] } # #[tokio::test], in [dev-dependencies]
cargo doc -p zapline --all-features --open builds the API reference with every module visible. The reference on docs.rs is built the same way.
Architecture
zapline has a small, fast core (routing, extractors, responses, errors, the server) and a set of feature modules built only on the core's public extension points. Everything lives in one crate, and each module that pulls in a dependency sits behind a Cargo feature (see Feature flags). This page is the short version. The full contract is in ARCHITECTURE.md at the root of the repository, which is the document to read before contributing.
Design rules
- No per-request cost for unused features. If an app doesn't use a module, the hot path doesn't change. Work happens at build time (
on_build, plugin installation) rather than per request. - FastAPI-level ergonomics. The common case is one line, errors are
zapline::Error, and?works everywhere. - Enterprise-grade control. Every default can be overridden, through builders for configuration and traits for behavior (
RateLimitKey,SessionStore,HealthCheck,Authenticator). - No
unsafeoutside the core server and no panics on request paths. Panics are reserved for invalid configuration at startup, such as a JWT secret that is too short. - Tested. Every module has integration tests, using the in-process test client where possible and a real socket where the feature needs one.
A request, end to end
TCP accept (one runtime per core, SO_REUSEPORT)
-> HTTP connection (hyper)
-> global middleware App::wrap before routing, sees 404s
-> router radix tree, then a method slot; sets the matched route and its params
-> route middleware App::wrap_route, Routes::wrap after routing
-> handler extractors -> your function -> IntoResponse
The consequences of that order show up throughout this guide: CORS, request IDs and path normalization are global; metrics, auth policies and per-route rate limits are route middleware, because they need the matched route. See Middleware and Routing.
Extension points
Plugins such as OpenAPI, Metrics and Health and Trace use the same APIs as your code:
| To do this | Use |
|---|---|
| Bundle routes, middleware, state and hooks | Plugin and App::plugin (any FnOnce(App) -> App is a plugin) |
| Inspect every route after the table is final, and add routes | App::on_build. This is how /openapi.json is generated once at startup. |
| Initialize resources before accepting connections | App::on_startup, with Resources behind State<T> and Dep<T> |
| Attach typed data to routes | Routes::meta, RouteInfo::meta::<T>(). Auth policies, rate limits and OpenAPI docs all work this way. |
| Re-render every error | App::error_handler with ErrorReport |
| Write extractors and responses | FromRequestParts, FromRequest, IntoResponse |
Where each module is documented
| Area | Modules | Chapter |
|---|---|---|
| Core | app, router, request, response, extract, error, lifecycle, test | Routing, Extractors, Responses, Errors, Lifecycle, Testing |
| Server | server, tls, http3 | Server |
| Middleware | middleware/* | Middleware, Hardening middleware |
| API docs and validation | openapi/*, validate, zapline-macros | OpenAPI, Validation |
| Application structure | di, config, tasks | Dependency injection, Configuration, Background tasks |
| Security | auth/*, cookies, session | Authentication, Cookies, sessions & CSRF |
| Realtime and content | ws, sse, stream, fs, multipart | WebSockets, Streaming, Static files & uploads |
| Observability | telemetry, metrics, health | Observability |
| Tooling | zapline-cli | Project layout & the CLI |
Contributing a feature
Add it as a module in the zapline crate behind a feature if it needs a dependency, build it on the extension points above (never add fields to Request or work to the request path for a single feature), and give every public type a doc comment with a short example. Run ./scripts/check.sh before opening a pull request, and include before and after ./bench/run.sh numbers for hot-path changes. ARCHITECTURE.md and CONTRIBUTING.md have the details.
Upstream issues
Bugs in dependencies that zapline works around, written up so they can be
filed (search the tracker for an existing report first) and re-checked on
each upgrade. Each one has a canary test in crates/zapline/tests/ that
fails once the upstream fix lands.
hyper: automatic error responses are dropped with pipeline_flush
Status: present in hyper 1.12.0. Workaround: ServerConfig::pipeline_flush = false, the default since the reliability audit.
Canary: hyper_drops_automatic_error_responses_with_pipeline_flush in
crates/zapline/tests/server.rs.
Why zapline can't fix it on its side: the lost bytes live in hyper's
private write buffer and are dropped with the connection. zapline could
write its own 400 to the socket after hyper reports a parse error, but on a
pipelined connection that response would arrive in place of the earlier
responses hyper also dropped, and the client would match it to the wrong
request. Knowing whether anything else was buffered would take bookkeeping
on every request, or a wrapper around every connection's IO type. Both cost
the hot path (the IO wrapper blocks LTO from inlining hyper's dispatcher;
see server::conn).
The draft below is ready to file at https://github.com/hyperium/hyper/issues.
Title: http1::Builder::pipeline_flush(true) drops the automatic 400/414/431 response (and buffered pipelined responses) on a request-head parse error
Version: hyper 1.12.0, server + http1 features, Linux, Tokio 1.
Description
With pipeline_flush(true), a request head that fails to parse (invalid
header line, invalid method/URI/version, head larger than
max_header_size/max_buf_size, URI too long) closes the connection without
sending the automatic error response. With pipeline_flush(false) the
same requests get 400, 431 or 414 as documented.
Responses to earlier requests on the same connection that were still
buffered are lost as well: a client that pipelines a valid request and a
malformed one in one write receives nothing at all, not even the 200 for
the first request.
Reproduction
// [dependencies] hyper = { version = "1.12", features = ["server", "http1"] } // hyper-util = { version = "0.1", features = ["tokio"] } // tokio = { version = "1", features = ["full"] } // http-body-util = "0.1" use std::convert::Infallible; use http_body_util::Full; use hyper::{body::Bytes, server::conn::http1, service::service_fn, Response}; use hyper_util::rt::TokioIo; #[tokio::main] async fn main() { let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); loop { let (stream, _) = listener.accept().await.unwrap(); tokio::spawn(async move { let svc = service_fn(|_req| async { Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("ok")))) }); let result = http1::Builder::new() .pipeline_flush(true) // false: the 400 arrives .serve_connection(TokioIo::new(stream), svc) .await; eprintln!("connection ended: {result:?}"); }); } }
printf 'GET / HTTP/1.1\r\nHost: x\r\nno colon here\r\n\r\n' | nc 127.0.0.1 3000
# expected: HTTP/1.1 400 Bad Request ... actual: nothing, connection closed
printf 'GET / HTTP/1.1\r\nHost: x\r\n\r\nGET / HTTP/1.1\r\nno colon\r\n\r\n' | nc 127.0.0.1 3000
# expected: HTTP/1.1 200 OK ... ok, then HTTP/1.1 400 actual: nothing
The server logs connection ended: Err(hyper::Error(Parse(Header(Token))))
in both cases.
Cause
Conn::on_read_head_error(src/proto/h1/conn.rs, line 339) callson_parse_error(line 836), which queues the error head withwrite_headand stores the error instate.error. The malformed bytes stay in the read buffer (consume_leading_linesonly strips leading CRLFs).Buffered::poll_flush(src/proto/h1/io.rs, line 278) returnsReady(Ok(()))without writing whileflush_pipeline && !read_buf.is_empty(), on the assumption that more pipelined requests are about to be parsed and their responses batched.- The read side is already closed, so nothing is parsed or flushed again.
Dispatcher::poll_inner(src/proto/h1/dispatch.rs, line 143) seesis_done(),take_error()returns the parse error, and the connection, with its unflushed write buffer, is dropped.
Suggested fix
Drop the unparseable input once the automatic response is queued: the
connection closes after it and never reads that buffer again, and an empty
read buffer lets poll_flush write. Error path only, so pipelined
throughput is unaffected.
--- a/src/proto/h1/io.rs
+++ b/src/proto/h1/io.rs
@@ -115,6 +115,11 @@
self.read_buf.as_ref()
}
+ /// Drop unread input: the connection will not parse it.
+ pub(crate) fn clear_read_buf(&mut self) {
+ self.read_buf.clear();
+ }
+
#[cfg(test)]
#[cfg(feature = "nightly")]
pub(super) fn read_buf_mut(&mut self) -> &mut BytesMut {
--- a/src/proto/h1/conn.rs
+++ b/src/proto/h1/conn.rs
@@ -843,6 +843,7 @@
// assert in `write_head`...
self.state.cached_headers.take();
self.write_head(msg, None);
+ self.io.clear_read_buf();
self.state.error = Some(err);
return Ok(());
}
Verified against 1.12.0 with zapline's test suite through
[patch.crates-io]: with pipeline_flush(true) a malformed head gets
400 Bad Request, an oversized one 431 Request Header Fields Too Large,
and the pipelined case gets the 200 followed by the 400.
An alternative is to skip the pipeline shortcut in Buffered::poll_flush
once the read side is closed (Conn::poll_flush knows state.is_read_closed()).
A regression test: serve with pipeline_flush(true), write
"GET / HTTP/1.1\r\nHost: x\r\n\r\nGET / HTTP/1.1\r\nbad\r\n\r\n" and expect
a 200 followed by a 400 before EOF.