Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. 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()).
  2. Everything is overridable. Defaults are built from public traits and builders, so you can swap any piece.
  3. 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.
  4. 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.

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 /todos parses and validates the body. Invalid input returns 422 with per-field details.
  • /openapi.json and Swagger UI at /docs are 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 cargo block 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:

CrateDefault
zaplinethis 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

CommandWhat 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.rs files 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_DIR still 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 run replaces 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

TaskHow
Typed path paramsPath(id): Path<u64>, Path((org, repo)): Path<(String, String)>
Query string / formQuery(q): Query<T>, Form(f): Form<T>
Validated inputValid(Json(body)): Valid<Json<T>> (also Valid<Query<T>>, Valid<Form<T>>)
Plain shared stateApp::state(value) + State(v): State<T>
Services / DIApp::provide(value) or provide_dyn::<dyn Trait>(arc) + Dep<T>
ErrorsError::bad_request(msg), not_found(), conflict(msg), unauthorized(), forbidden(), .with_code("code")
Domain errorsimpl From<MyError> for zapline::Error, then ?
HTML / redirectHtml(string), Redirect::to("/path")
Route group + prefixApp::nest("/api", Routes::new().mount((a, b)))
Group middleware / authRoutes::wrap(mw), .require_roles(["admin"]), .wrap(Authenticate::jwt())
ConfigConfig::load()? reads config/default.toml, config/{profile}.toml, APP__* env and .env
Startup / shutdownApp::on_startup(|res| async move { res.insert(pool); Ok(()) }) (then State<Pool>), App::on_shutdown(..)
Background workBackgroundTasks extractor, App::schedule(Schedule::every(dur), job), Schedule::cron("0 9 * * MON-FRI"); once per cluster: .jobs(|q| q.cron("@daily", MyJob {}))
Live updatesApp::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 featureszapline 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)
RealtimeWebSocketUpgrade (ws feature), Sse
One binary + deployzapline::embed!("templates") wherever a dir goes (disk in debug); zapline ship user@host (Deployment)
Static filesApp::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
DatabaseApp::database(Database::from_env()) + db: Db, then sqlx::query_as(..).fetch_one(&*db) (Databases)
List endpointsparams: ListParams + Listing::new("posts", "id, title").sortable(["title"]).search(["title"]).filter_bool("done").fetch(&db, &params).await? returns Paged<T> (?limit=&offset=&sort=-id&q=&done=true)
Migrationszapline db new create_posts (up + down SQL), applied at startup or by db migrate; db status/rollback/redo/reset
Test data / seedsFactory::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 + htmxApp::views("templates") + views: Views, hx: HxRequest, then views.page_or_block(&hx, "page.html", "block", context!{..}) (Views)
Admin panelApp::admin(|admin| admin): every table at /admin (debug + localhost); production: .protect(middleware); .exclude([..]), .hide(["users.email"]), .read_only() (Admin panel)
TranslationsApp::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 instanceslet 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)
OpenTelemetryfeature otlp, then OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 (or Telemetry::new().otlp(url)); requests become spans, traceparent is continued (Observability)
CachingApp::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)
FilesApp::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)
Emailmail.send(Email::to(a).cc(b).bcc(c).attach(name, type, bytes)); .template(&views, "mail/x", ctx); /_dev/mail; Mailer::outbox().sent() (Email)
Dev loopzapline 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 consoleApp::dev_console(): routes, requests, errors (with ZL codes and fixes), jobs, mail at /_dev (debug, localhost only)
Typed frontenda 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
Testsapp().test().get("/x").bearer(token).send().await then .status(), .json::<T>(), .text()
Fuzz every routeapp().test().fuzz().await.assert_clean() or zapline fuzz
Tests from trafficzapline dev --record, use the app, zapline snap signup; replayed by app().test().replay_snapshots("tests/snapshots")
CLIzapline 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 + flashValidForm(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>.

CommandWhat 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 migrateCreate a SQL migration (an .up.sql + .down.sql pair) / apply pending ones (Databases)
zapline db statusEvery 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 redoRevert the last N migrations with their .down.sql files / revert the last one and apply it again
zapline db resetRevert 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 seedInsert the app's seed data (App::seed) after migrating
zapline g handler <name> --method get --path /xAdd 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 authAdd 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|composeHosting config for the project's Dockerfile, with volumes, databases, health checks and secrets filled in
zapline add <feature>... / zapline add --listTurn 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 tailwindTailwind 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 mcpServe 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 --recordSame, 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 doctorCheck 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 changeWhat happensRust process
templates/, locales/, i18n/, files in static/, public/, assets/Open pages reload, keeping their scroll positionKeeps 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 reloadKeeps running
src/, config/, migrations/, Cargo.toml, build.rs, .envRebuild in the background; pages show a small "Rebuilding" badgeThe old version keeps serving until the new one answers, then gets SIGTERM
...and the build failsPages show the compiler errors (file, line, rustc's message) as an overlayThe 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)
  1. zapline dev starts the app with ZAPLINE_DEV_PROXY=<file>. App::run then 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 without ZAPLINE_DEV=1 the variable is ignored.
  2. 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 a script-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.
  3. The client opens an EventSource on /__zapline/events. The CLI classifies each file change (Rust or asset), and pushes reload, css, building, problem (the parsed cargo build --message-format=json errors) or clear.
  4. 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.
  5. The proxy tells the app who the browser is with X-Forwarded-For, plus a random per-run key (ZAPLINE_DEV_PROXY_KEY in the app's environment, x-zapline-dev-proxy on each request). The app's local-only pages (the dev console at /_dev, the dev inbox, dev error pages, an unguarded admin) trust that X-Forwarded-For only 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 to 0.0.0.0 from 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 with App::serve on 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 use App::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
FileContents
migrations/<ts>_create_posts.sqlThe 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.rsPost, 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.htmlThe list with an add form; each row has edit and delete buttons
templates/posts/show.html, edit.htmlGET /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.rsCRUD, PATCH, validation, the pages and the nested list, against an in-memory SQLite database
TypeRustSQLitePostgresForm input
string (default)String, 1 to 200 charsTEXTTEXTtext
textStringTEXTTEXTtextarea
int, floati64, f64BIGINT, DOUBLE PRECISIONsamenumber
boolboolBOOLEANBOOLEANcheckbox
datechrono::NaiveDateTEXTDATEtype=date
datetimechrono::DateTime<Utc>TEXTTIMESTAMPTZtype=datetime-local (read as UTC)
uuiduuid::UuidBLOB (how sqlx stores it)UUIDtext
jsonJsonValue (serde_json::Value)TEXTJSONBtextarea holding JSON
author:referencesauthor_id: i64BIGINT REFERENCES authors(id) ON DELETE CASCADEsame<select> of authors
author:ref:usersauthor_id: i64BIGINT REFERENCES users(id) ON DELETE CASCADEsame<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
ModifierMigrationAPI
: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:users names the column and the error code after author (author_id, author_not_found) and points the foreign key at users.
  • Generate the parent first: when no migration creates the parent's table, the generator stops with run zapline g resource author first and 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? (or category:references?), with GET /api/categories/{id}/categories listing 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}/posts would 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::run prints 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 interface per schema, with the doc comments of your Rust types;
  • createClient({ baseUrl?, headers?, fetch?, credentials?, csrf? }) and a ready same-origin api, with one method per operation, named after its operationId (the handler name, prefixed with its module when two modules share a name: usersList, postsList). Path parameters come first, then the body, then a query object, then per-call options ({ signal, headers });
  • ApiError for non-2xx responses, with status, and zapline's code and details; a ValidationError for 422s, whose fields are 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:

ToolReturns
docsNo 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_projectCreates a project from a template
add_featureTurns 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_mailerThe files created and edited
db_new_migrationCreates a migration, with its up (sql) and down (down_sql) SQL if given
db_migrateApplies pending migrations and names them
db_statusThe zapline db status table, and the file to restore when an applied migration was edited or deleted
db_rollback, db_redo, db_resetRevert migrations. They refuse to run without confirm=true, so the assistant asks you first
check, testThe condensed output of zapline check / zapline test
routesOne METHOD path line per route
openapiThe OpenAPI document, compact
typescript_clientWrites the TypeScript client
explain_errorA diagnostic code's cause and fix
last_resultsThe 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:

ToolReturns
recent_errorsThe 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_requestsOne 500 GET /orders 0.8ms ZL0002 line per request
call_routeSends 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

FastAPIzapline
@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 errorsValid<Json<T>> → 422 with per-field details
/docs, /openapi.jsonApp::openapi(OpenApi::new("API", "1.0"))
Depends(get_db)Dep<T>, State<T>, custom extractors, Cached<T>
BackgroundTasksBackgroundTasks
HTTPException(404)Err(Error::not_found())
TestClient(app)app.test()
uvicorn --reloadzapline dev
Lifespan eventson_startup / on_shutdown

Spring Boot / ASP.NET Core / NestJS

Conceptzapline
ControllersModules of #[get]/#[post] handlers mounted with .mount((...)), or Routes groups
Beans and services, constructor injectionApp::provide, App::provide_dyn::<dyn Trait>, async provide_with factories, the Dep<T> extractor
application.yml profilesConfig::load(): config/default.toml + config/{profile}.toml + APP__* env vars + .env
Filters, interceptorsMiddleware: 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
@ScheduledApp::schedule(Schedule::every(..), job)
Exception handlersApp::error_handler
Modules / startersPlugins

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 no serve glue.
  • There's no tower Service/Layer stack. Middleware is async fn(Request, Next) -> Response.
  • State is keyed by type, so you can register many types and the router has no S generic 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_REUSEPORT listeners 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

PatternMatchesExtract with
/usersexactly /users—
/users/{id}/users/42Path<u64>
/orgs/{org}/repos/{repo}/orgs/acme/repos/webPath<(String, String)> or a struct
/files/{*path}/files/a/b/c.txtPath<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 Allow header for any other method.
  • HEAD is served automatically by the GET handler (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:

RejectionStatuscodedetails
Bad route parameter400invalid_path{"param": "id"}
Bad query string400invalid_query
Bad form body422invalid_form
Malformed JSON422invalid_json{"line": 1, "column": 9}
JSON of the wrong shape (missing field, wrong type...)422invalid_body{"line": 1, "column": 22, "path": "items[0].qty"}
Wrong Content-Type for Json / Form415unsupported_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

ExtractorSourceFailure
Path<T>Route parameters: a scalar, a tuple, or a struct with field names matching the parameters400
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 body415 / 413 / 422
Bytes, StringRaw body413 / 400
State<T>App state registered with .state(v) or a startup hook500 if not registered
Extension<T>A value a middleware inserted with req.extensions_mut().insert(v)500
HeaderMap, Method, UriRequest metadata—
ConnInfoPeer address, local address, TLS flag—
MatchedPathRoute pattern, e.g. /users/{id}500 outside a route
RequestThe whole request (last argument)—
Option<T>Any parts extractor, made optionalnever fails
Result<T, Error>Any parts extractor, with its error handled by younever 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 typeResponse
&'static str, String200 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 / permanent303 / 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
ResponseFull 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::Error converts into a 500 with ?. The underlying error is logged (through tracing when 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), or Error::new(status, msg).

  • Errors are structured. Each one renders as JSON:

    { "error": "validation failed", "code": "validation_error", "details": [ ... ] }
    

    code and details are 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
WhereWhat 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 pageThe code, the cause, the fix and a prompt for your AI assistant
The dev console and GET /_dev/apiRecent errors with their codes and fixes
zapline explain ZL0002The full entry; zapline explain lists them all
zapline mcpexplain_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"}
    

    (error repeats detail so clients that read the usual {"error": ..} keep working; error_code is the client-facing code.)

  • Rejected requests (a 4xx the framework recognized, such as invalid_json) keep their normal body and gain an x-zapline-diagnostic: ZL0062 header.

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

CodeTitle
ZL0001State not registered
ZL0002No provider for a dependency
ZL0003A dependency failed to initialize
ZL0004Resource not registered
ZL0005Request extension missing
ZL0010Duplicate route
ZL0011Invalid or conflicting route path
ZL0012Route metadata without a route
ZL0013Route information used before routing
ZL0020Cargo feature not enabled
ZL0021Database URL does not match the backend
ZL0030Config file unreadable
ZL0031Config file malformed
ZL0032Config key missing
ZL0033Config value has the wrong type
ZL0034Invalid config profile name
ZL0040Database URL not set
ZL0041Cannot connect to the database
ZL0042Migrations unreadable
ZL0043Migration failed
ZL0044No database registered
ZL0045Database registered too late
ZL0046No seed data
ZL0050No templates registered
ZL0051Template error
ZL0060Path parameter rejected
ZL0061Query string rejected
ZL0062Malformed JSON body
ZL0063JSON body has the wrong shape
ZL0064Wrong Content-Type
ZL0065Form body rejected
ZL0066Validation failed
ZL0067Body too large
ZL0068Body too slow
ZL0070No mailer registered
ZL0071No cache registered
ZL0072No Redis registered
ZL0073No cookie key
ZL0074No CSRF middleware
ZL0075JWT not configured
ZL0076No translations registered
ZL0077No file storage registered
ZL0078Authorization misconfigured
ZL0080Handler panicked
ZL0081Unhandled error
ZL0090Dev console not mounted
ZL0091Dev 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
KindRegistered withSees 404s?req.route() known?Typical use
GlobalApp::wrapyesnoCORS, request IDs, path normalization, tracing, compression, security headers
RouteApp::wrap_routenoyesmetrics (route labels), auth policies, per-route rate limits
GroupRoutes::wrapnoyesauth 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

MiddlewarePurpose
LoggerOne line per request: method, path, route, status, latency
CorsCORS with permissive or allow-list configuration and preflight handling
TimeoutFail requests that exceed a duration with 503
DefaultHeadersAdd headers to every response
Compressiongzip, br, zstd or deflate, negotiated with q-values (feature compression)
RateLimitPer-IP, per-key, global and per-route limits with standard RateLimit-* headers
SetRequestId / RequestIdGenerate and propagate x-request-id
SecurityHeadersHardened default response headers (HSTS, nosniff, frame options, CSP, ...)
CatchPanicTurn handler panics into 500s
ConcurrencyLimitCap in-flight requests, with load shedding
NormalizePathTrailing-slash and duplicate-slash normalization
telemetry::Tracetracing 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::run returns 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. Call client.start().await to run them eagerly, and client.shutdown().await to 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

MethodEffect
.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):

AttributeIn the test store
DomainWithout 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.
PathPrefix match, defaulting to the request's directory. Longer paths are sent first.
SecureStored 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.
SameSiteApplied 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, ExpiresMax-Age wins. Expired cookies are deleted and never sent.
HttpOnlyKept, 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:

MethodReturns
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, -1 and 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 minLength or longer than maxLength, 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:

FailureMeaning
panickedThe handler (or a middleware) panicked. Panics caught by CatchPanic count too.
server errorAny 5xx.
timed outNo response within the timeout (5 s).
undeclared statusA 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 schemaA 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.

  1. Record. Start the app with zapline dev --record (it sets ZAPLINE_RECORD=1), or press Start recording at /_dev. Then use the app: a browser, curl, your frontend.

  2. Save. Run zapline snap signup, or name the snapshot in the console and press Save as snapshot test. You get tests/snapshots/signup.json and, 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;
    }
    }
  3. Run. cargo test replays every file in tests/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
    
  4. Accept changes. When the new responses are right, ZAPLINE_UPDATE_SNAPSHOTS=1 cargo test --test snapshots rewrites 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:

ValueExampleCompared as
UUIDs67e55044-10b1-426f-9247-bb680e5fe0c8[uuid]
Timestamps2024-05-01T12:30:00Z, HTTP dates; any value under created_at, updatedAt, timestamp, exp...[timestamp]
Tokenssession ids, API keys, JWTs, hex digests: long strings that mix letters and digits[token]
Asset hashes/static/app.73cd192d.cssapp.[hash].css
Idsany 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:

CallEffect
.awaitReplay, 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().awaitReturn 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.

PanelShows
Recent errorsThe last 20 errors (500s, panics and rejected requests), with their diagnostic code, the real message and the fix.
Recent requestsThe last 50 requests: status (4xx and 5xx highlighted), method, path with query, and how long the app took.
RecordingWhether requests are being recorded, the latest recorded ones, and buttons to start or stop recording, Save as snapshot test and clear. See below.
RoutesEvery route with its summary and tags (from doc comments and #[get(.., tag = "..")]). Parameterless GET routes are links.
JobsWhen 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 tasksWhen 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.
MailWhen 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 to 0.0.0.0 doesn'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 in target/zapline/recording.jsonl, so the restarts zapline dev does 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 to tests/snapshots/<name>.json, plus tests/snapshots.rs (which replays them) if the project has none. Saving empties the recording, so the next snapshot starts fresh.
  • Authorization, Cookie and 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, implies json) and macros (on by default) for the automatic parts.

  • Types appear in the spec when they implement JsonSchema. #[model] derives it for you (along with serde and Validate) with no extra dependency. The plain derive expands to paths in the schemars crate, so add it to your Cargo.toml:

    schemars = "1"
    

    If you would rather not depend on schemars directly, point the derive at the re-export with #[schemars(crate = "zapline::openapi::schemars")] (the trait is zapline::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 typeDocumented 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, Stringapplication/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 strtext/plain response
Bytes, Vec<u8>application/octet-stream response
Html<T>text/html response
(), StatusCodeResponse without a body
Redirect303 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 = "...".

ArgumentExampleEffect
tagtag = "users"Adds a tag. Docs UIs group operations by tag.
tagstags("users", "admin") or tags = ["users", "admin"]Adds several tags
summarysummary = "Create a user"One-line summary. Overrides the doc comment.
descriptiondescription = "Longer text (CommonMark)"Overrides the rest of the doc comment
operation_idoperation_id = "createUser"Explicit operationId. Must not be empty.
statusstatus = 201Documents the success status. It does not change the real status. Return (StatusCode::CREATED, ...) yourself.
deprecateddeprecatedMarks the operation deprecated
hiddenhiddenLeaves the route out of the spec
securitysecurity = "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.

MethodEffect
.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:

MethodEffect
.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

DocsUiLooks like
SwaggerSwagger UI. Lets you try requests in the browser.
ScalarScalar. A modern reference with a built-in client.
RedocRedoc. 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 IntoResponse gets a generic success response, because its type cannot be named. Document it with .response::<T>(..). Wrappers such as Result<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 implement ApiInput. 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 haveYou getHow
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 typesOpenApi::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> fieldsinterfaces, field?: T | null
unit enumsstring 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-allspositional 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 bodiesJSON, URL-encoded, FormData, text and binary bodies
Json<T>, String, Bytes, Sse, () / StatusCode resultsT, 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 error details stay unknown.
  • 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.js gives 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, implies json).

  • #[model] derives Validate (plus serde and JsonSchema) without adding any crate. The plain derive expands to paths in the validator crate, so add it to your Cargo.toml:

    validator = { version = "0.21", features = ["derive"] }
    

    zapline::validate re-exports the crate (zapline::validate::validator, plus Validate, ValidationError and ValidationErrors). 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 writeSourcePosition
Valid<Json<T>>JSON bodylast argument
Valid<Form<T>>URL-encoded form bodylast argument
Valid<Query<T>>query stringany
Valid<Path<T>>route parametersany
#![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:

KeyMeaning
fieldPath to the field: email, address.zip, contacts[1].name. Empty for struct-level (schema) errors on the top-level value.
codeThe rule that failed: email, length, range, url, must_match, ... or the code of your custom validator
messageThe rule's message = "..", or a readable default such as length must be between 1 and 64
paramsThe 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.

RuleExample
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

MethodWhat 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_arc or provide_dyn (those are available immediately) and every factory registered before it.
  • A factory that fails aborts startup. App::run returns 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 into zapline::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 withExtracted asNotes
Plain stateApp::state(value), or res.insert(value) in a hookState<T>Cloned per request, so T: Clone. Fine for small configuration or handles that are already cheap to clone.
ProviderApp::provide*Dep<T>Held as Arc<T>; T may be unsized (dyn Trait). Built by factories and replaceable in tests.
Typed settingsApp::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:

LayerExample
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:

VariableSets
APP__SERVER__PORT=9000server.port
APP__DATABASE__MAX_CONNECTIONS=50database.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. So 007, 0x1F and 1.10 are kept as written.
  • Typed extraction is lenient where environment variables need it: "8080" reads as a number, 8080 as a string, "a,b" as a list, and yes, on, 1 as true.
  • 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.

MethodEffect
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

MethodReturns
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 () or Result<(), E> where E: Display. Errors and panics are logged (through tracing when enabled, otherwise on stderr) and never affect the client or the server.
  • A task must be Send + 'static. Dep<T> is an Arc underneath, 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)))
}
}
MethodEffect
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");
            },
        )
}
}
ScheduleMeaning
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:

ModifierEffect
.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) and schedule.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.

SeriesTypeLabels
scheduled_task_runs_totalcountertask, outcome (ok, error, panic)
scheduled_task_duration_secondshistogram (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:

MethodEffect
wait().awaitResolves 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:

  1. Fires Shutdown, which stops schedules and tells workers to return.
  2. Waits for workers, queued BackgroundTasks and overlapping job runs to finish.
  3. 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:

RunsUse it for
App::schedule(Schedule::cron(expr), job) (tasks)in every instance of the app, in memoryrefreshing a local cache, per-instance housekeeping
.jobs(|q| q.cron(expr, job)) (durable jobs)once per tick for the whole cluster, stored and retriedreports, 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:

ItemMeaningExample
*every value* * * * * every minute
none value0 12 * * * at noon
a-ba range, which may wrap around0 22-2 * * * 22:00 to 02:00; FRI-MON
*/nevery n-th value*/15 * * * * every 15 minutes
a-b/nevery n-th value in a range0 9-17/2 * * * 09:00, 11:00, ... 17:00
a/nevery n-th value from a5/20 * * * * minutes 5, 25, 45
namesmonths and weekdays, any case0 0 1 JAN,jul *

Extensions in the day fields (from Quartz):

ItemFieldMeaning
?either day fieldsame as *
Lday of monththe last day of the month (28th to 31st)
L-nday of monthn days before the last day: L-2
nWday of monththe weekday nearest the n-th, inside the month: 15W
LWday of monththe last weekday of the month
d#nday of weekthe n-th such weekday of the month: FRI#2, 1#1
dLday of weekthe 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:

MacroSame as
@yearly, @annually0 0 1 1 *
@monthly0 0 1 * *
@weekly0 0 * * 0
@daily, @midnight0 0 * * *
@hourly0 * * * *

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>):

ZoneExampleNeeds
UTCCRON_TZ=UTC, Z, GMTnothing
A fixed offsetCRON_TZ=+05:30, -0800, UTC+2nothing
An IANA zoneCRON_TZ=America/New_Yorkthe 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>(())
}
MethodReturns
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:

SkipUse
Recomputing a value (a slow query, an API call)Cache
Running a handler at all for the same anonymous GETResponseCache
Sending a body the browser already hasETag

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:

  • GET or HEAD with no Authorization or Cookie header, so logged-in users always get their own page;
  • a 200 with a buffered body, no Set-Cookie, and no Cache-Control: private, no-store or no-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

ConstructorUse
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 errorHTTP
RowNotFound404
unique violation409, code unique_violation
foreign-key violation409, code foreign_key_violation
not-null / check violation422, code constraint_violation
pool timeout503, code db_pool_timeout
anything else500 (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).

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, &params)
        .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}
QueryMeaning
limit, offsetPage size (default 50, capped at 200; change with .page_size(default, max)) and start.
sort=title, sort=-title, sort=-done,titleAscending, descending, several keys. Only .sortable(..) columns and the key (id) are allowed. Ties are broken by the key, so pages are stable.
q=wordsCase-insensitive substring match over the .search(..) columns. % and _ match themselves.
published=true, author_id=7, status=paidEquality 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, redo and reset refuse --release (a release build) and APP_ENV/ZAPLINE_ENV=production unless you add --force.
  • The app itself, built in release mode, refuses ZAPLINE_MIGRATE=down:<n>, redo and reset unless ZAPLINE_ALLOW_DESTRUCTIVE=1 is set (--force sets 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:

  1. A startup factory opens the pool and runs migrations (App::database for sqlx, App::provide_with for anything else). If it fails, run returns the error and the port is never bound.
  2. A service built from the pool by a second factory, received in handlers as Dep<Service> (see Dependency injection).
  3. 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:

ExampleLibraryShows
sqlx-transactionssqlx through zapline::dbTransactions, savepoints, idempotency keys, concurrent writers, a health check that audits the books
seaormSeaORM 1.1Entities and relations, sea-orm-migration, eager loading, pagination, partial updates, a multi-step transaction
dieselDiesel 2.3A blocking ORM on the async server, embedded migrations, stock reservation that never oversells
leptos-ssrLeptos 0.8Leptos 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 >= $1 can't be raced; a SELECT followed by an UPDATE can. Look at rows_affected() to tell why nothing changed.

  • Savepoints for partial success. tx.begin() inside a transaction issues SAVEPOINT; commit releases it and rollback undoes 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 UNIQUE column read and written in the same transaction: a retried POST with the same Idempotency-Key header gets the original result (200), and the same key with a different body is a 422.

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: Flashflash.success(msg) / .error / .info / .warning, flash.redirect(to); {% for m in flash %} in templates (Flash messages)
HxRequestExtractor: htmx, boosted, history_restore, target, trigger from the HX-* headers
Hx::redirect / refresh / trigger / retarget / push_urlhtmx 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 handler Ok(T), or Err(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, the validator rules run, with the same messages as the JSON 422 (Validation). Only a request that isn't a form at all (wrong Content-Type, too large) is rejected, like Form<T>.
  • errors is a FormErrors: {{ errors.title }} is the first message for title, undefined (falsy) when the field is fine; {{ errors._form }} holds errors about the whole form. Build one by hand with FormErrors::one(field, msg) / .add(field, msg), or from any error with FormErrors::from_error(&err).
  • old holds 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_invalid answers 422 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 an id but posts somewhere else (hx-target="#list", a create form above a list), it adds HX-Retarget: #post-form and HX-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), or flash.take() in a handler (for JSON clients). A message queued while rendering a page shows on that page.
  • flash.redirect(to) answers 303 See Other, or HX-Redirect for 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 any HX-Trigger the handler set. Show them as toasts with a few lines of script listening for the flash event (e.detail.messages); the fullstack layout does.
  • No cost when unused: flash is 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.
  • NAME identifies the job type in the database. Keep it stable when you rename or move the struct, or queued jobs will find no handler.
  • run gets the app's Resources, 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

CallRuns
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 returns Ok(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. enqueue reports duplicates itself instead of a separate enqueue_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).

SettingDefault
.workers(n)4Jobs 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, sharedDedicated workers per queue: ("mail", 2), or a bare name for .workers(n) workers.
.poll_interval(d)1 sHow often idle workers look for due jobs. Jobs enqueued by the same instance wake a worker at once.
.timeout(d)5 minA 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:

SeriesTypeCounts
jobs_enqueued_total{job}counterjobs added, by enqueue, enqueue_in or a cron tick
jobs_started_total{job}counterruns started (one per attempt)
jobs_succeeded_total{job}counterruns that succeeded
jobs_failed_total{job}counterruns that failed: returned Err, panicked or timed out
jobs_retried_total{job}counterfailed runs that were scheduled again
jobs_dead_total{job}counterjobs 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?

BackgroundTasksJobs
Storedin memoryin the database
Survives a crash or deploynoyes
Retriesnowith backoff
Shared across instancesnoyes
Needsnothinga 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.

Email

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:

VariableExample
SMTP_URLsmtps://user:pass@smtp.example.comImplicit TLS (port 465). Use smtp://user:pass@host:587?tls=required for STARTTLS and smtp://localhost:1025 for a local catcher like Mailpit.
MAIL_FROMAcme <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

ShownNot 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 sizeColumns 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.

SettingEffect
.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:

  1. ?lang=fr in the URL. The app then remembers it in a lang cookie (one year, SameSite=Lax), so a language switcher is just links: <a href="?lang=fr">Français</a>.
  2. The lang cookie.
  3. The Accept-Language header, by its weights.
  4. The default: en if there is an en.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

CallResult
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:

  1. Authentication (who is it?) is done by an Authenticator, run by the Authenticate route middleware. It stores the user for the CurrentUser<U> extractor.
  2. Authorization (may they?) is declared as route policies (.require_auth(), .require_roles(..), .require_scopes(..)) and enforced by Authenticate (with .authorize()) or by Guard.

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
FileWhat it holds
migrations/<ts>_create_users.sqlusers table (unique lowercased email, Argon2id password hash), password_resets (hashed single-use tokens) and oauth_accounts (linked GitHub/Google identities)
src/routes/auth.rsPOST /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/*.htmlhtmx 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.rsSign-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.

ProviderConstructorNotes
GitHubOAuth::github(id, secret)Scope read:user user:email; the email is the verified primary one from /user/emails
GoogleOAuth::google(id, secret)OpenID Connect userinfo; scopes openid email profile
Any OAuth 2 providerOAuth::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.

ExtractorHeaderChallengeCheck with
BearerTokenAuthorization: Bearer <token>Bearertoken.token() and constant_time_eq
BasicAuthAuthorization: Basic <base64>Basic realm="Restricted", charset="UTF-8"auth.verify(user, pass)
ApiKeyx-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

ConstructorAlgorithm
JwtConfig::hs256(secret), hs384, hs512HMAC 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_pemFallible 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 methodEffect
.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

FieldNotes
sub, exp, iat, nbf, iss, audRegistered claims. aud accepts a string or an array.
rolesA string or an array. Checked by .require_roles(..).
scopesAn array, or the OAuth scope / scp claim as a space-separated string. Checked by .require_scopes(..).
extraEvery 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 constructorAlgorithm
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:

MethodEffect
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 kid names a key in the ring, only that key is tried. The key's algorithm must match the token's alg.
  • Keys without a kid (a config built without .key_id(..)) accept any kid with their algorithm, so configs that predate key ids keep working.
  • A token without kid verifies only when the choice is unambiguous: a key without a kid, 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::jwt fetches 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-up kids 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 misleading 401.

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 methodEffect
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().awaitFetch 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 a 401, 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 with 401 missing_credentials and 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).
MethodEffect
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:

MethodRequirement
.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 403 with the code forbidden. A missing user gives 401 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" or Routes::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:

SituationResponse
A policy, but no authentication middleware ran401 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 them500 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.

ConstructorRule
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

JarProtectionThe client can...
CookieJarNoneread and change it
SignedCookieJarHMAC-SHA256 signatureread it, but not change it
PrivateCookieJarAES-256-GCM encryptionneither 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 methodEffect
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 the Cookie header.
  • 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 503 and the code session_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 Session fails with 500 session_layer_missing. Without a cookie key, the layer fails with 500 cookie_key_missing.

SessionLayer options

MethodDefaultEffect
SessionLayer::new(store)Sessions kept in store
.cookie_name(name)zapline.sidCookie name (panics on an invalid token)
.ttl(duration)24 hoursIdle timeout
.secure(bool)trueSecure attribute. Browsers drop Secure cookies on plain http:// (most make an exception for localhost). Disable it only for local development.
.http_only(bool)trueHttpOnly attribute
.same_site(SameSite)LaxSameSite attribute
.path(path) / .domain(domain)/ / host-onlyCookie scope
.key(key)the app's keySign 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 is Secure, has Path=/ and no Domain, so other hosts can't set it at all. The default name has it whenever the cookie is Secure (the default). With .secure(false), for development over plain HTTP, the default name is zapline.csrf, because browsers would drop a __Host- cookie without Secure.
  • 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 SessionLayer is 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) gets 403 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) or destroy() (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 Session still 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 methodDefaultEffect
.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-tokenRequest header carrying the token
.form_field(name)csrf_tokenURL-encoded form field carrying the token
.exempt(path)noneSkips the check for an exact path, or for a prefix when it ends in * ("/webhooks/*")
.secure(bool)trueSecure attribute of the token cookie. Disable it only for local development; the default name then drops its __Host- prefix.
.same_site(SameSite)LaxSameSite attribute of the token cookie
.key(key)the app's keySign 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-data bodies are never inspected; send the token in the header instead (for example, in a fetch call).
  • APIs that authenticate with an Authorization header don't need CSRF. Browsers don't attach that header on their own. Apply Csrf only to the cookie-authenticated part of the app, for instance with .exempt("/api/*"), or use Routes::wrap on 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) or Strict, 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" })
}
}
MiddlewareProtects againstFeature
SecurityHeadersClickjacking, MIME sniffing, downgrade attacks, over-broad browser features
RateLimitBrute force, scraping, noisy clients
CompressionBandwidth cost (not an attack, but it is why you want it)compression
ConcurrencyLimitOverload: sheds load instead of collapsing
TimeoutHandlers (or dependencies) that hang
CatchPanicA bug in one handler dropping connections
NormalizePath/users vs /users/ vs //users inconsistencies
SetRequestIdUnattributable errors
CorsCross-origin access you didn't intend
SameOriginCross-site request forgery against cookie sessions
DefaultHeadersAdds 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:

HeaderValue
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Referrer-Policystrict-origin-when-cross-origin
Cross-Origin-Opener-Policysame-origin
X-DNS-Prefetch-Controloff
Permissions-Policycamera, 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").
MethodEffect
.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?

ConstructorCounts 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)
}
}
MethodEffect
.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")
}
}
MethodEffect
.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 a Content-Encoding or a Content-Range, responses with Cache-Control: no-transform, bodies below min_size and 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 and woff/woff2.
  • text/event-stream is skipped by default, because buffering encoders delay events. Opt in with content_types if you need it (the encoder flushes whenever the source pauses).
  • It sets Content-Encoding, adds Vary: accept-encoding, drops Content-Length and Accept-Ranges, and weakens a strong ETag to W/"...".
  • 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) plus CatchPanic: 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)
}
}
MethodEffect
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
}
}
MethodEffect
.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 / methodEffect
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 explicit Cors::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 Origin get Vary: origin.
  • Put Cors outside rate limiting, concurrency limits and authentication, so a 429, 503 or 401 still carries CORS headers. Otherwise the browser reports an opaque CORS error instead of your real error.
  • Native apps and curl send no Origin and 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:

  1. SetRequestId, then Trace, outermost. Every later layer, including the ones that reject a request, shows up in the logs with an ID. Trace records the ID, so SetRequestId goes first.
  2. CatchPanic inside Trace. Trace then sees the 500 the panic became and logs it at ERROR. Panics in the outer layers are not expected.
  3. NormalizePath before everything keyed on paths (CSRF exemptions, Trace::skip_paths, RateLimit::exempt), and necessarily before routing.
  4. SecurityHeaders and Cors outside the limiters. Rejections (429, 503, 401) are responses too. They need the same headers, or the browser can't show the real error.
  5. ConcurrencyLimit outside Timeout. A timed-out request frees its slot. Shed load as early as possible.
  6. Compression innermost 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.
  7. RateLimit as wrap_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, and ConcurrencyLimit still covers floods. Use App::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:

ProblemStatusCode
Not a GET request400ws_invalid_method
Missing Connection: upgrade or Upgrade: websocket426 with Upgrade: websocketws_upgrade_required
Sec-WebSocket-Version is not 13426 with Sec-WebSocket-Version: 13ws_unsupported_version
Missing or malformed Sec-WebSocket-Key400ws_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.

MethodDefaultEffect
.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 KiBPer-connection read buffer; it grows for larger messages. Raise it for sockets that mostly receive large messages.
.write_buffer_size(bytes)128 KiBOutgoing data is buffered until this many bytes are queued. 0 writes eagerly. Every send flushes regardless.
.max_write_buffer_size(bytes)unlimitedCap on the outgoing buffer when writes are failing. Kept above the write buffer size.

The WebSocket

MethodEffect
recv().awaitThe next Message. None once the connection is closed (after the close handshake, or if the peer went away).
send(msg).awaitSends and flushes. Accepts a Message, String, &str, Bytes, Vec<u8> or &[u8].
close().awaitStarts the close handshake with code 1000. Keep calling recv until it returns None, or just drop the socket.
close_with(code, reason).awaitCloses with a code from zapline::ws::close_code and a reason (at most 123 bytes are sent)
protocol()The negotiated subprotocol
send_json(&value).awaitSerializes with serde_json and sends a text message (needs the json feature)
recv_json::<T>().awaitThe 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:

VariantConstructorHelpers
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()
anyMessage::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 Authorization header on a WebSocket. Authenticate with the session or cookie that the handshake request already carries (extract Session or CurrentUser<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, ConcurrencyLimit and RateLimit apply 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 its max_connections slot, or counts against ServerConfig::max_upgraded if you set it (upgrades beyond it get 503, code ws_too_many_connections). For per-user limits, keep a count in state, and use max_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 long proxy_read_timeout.

  • Shutdown. The drain waits for open sockets. Each one sends a 1001 close frame (close_code::AWAY) the next time your callback reads or writes, so a recv loop ends by itself and a later send fails with ws_closed. Callbacks still running after shutdown_timeout are dropped. If your loop waits on something else, put socket.recv() in the same select! so the close goes out at once. New upgrades during the drain get 503 (ws_shutting_down).

  • HTTP/2. With the http2 feature, the same handler also serves WebSockets over HTTP/2 (RFC 8441), over TLS (ALPN h2) and h2c. The server advertises SETTINGS_ENABLE_CONNECT_PROTOCOL; a client opens a socket with an extended CONNECT and :protocol = websocket. zapline routes it like the HTTP/1.1 GET (handlers and middleware see GET), validates Sec-WebSocket-Version: 13, negotiates subprotocols, and answers 200 instead of 101. 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.

MethodWire fieldNotes
.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):textIgnored 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 EventSource uses 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-stream by default, because encoders buffer. See Hardening middleware.
  • Timeouts. Timeout bounds the time to the response head, not the lifetime of the stream.
  • Authentication. EventSource can'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)
}
}
MethodEffect
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/
}
}
MethodDefaultEffect
ServeDir::new(root)Serve files under root (relative to the working directory). The directory may be created after startup.
.index_file(name)index.htmlThe 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)noneServe 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)noneSets Cache-Control on file responses. Panics on an invalid value.
.precompressed_gzip()offServe file.gz instead of file when the client accepts gzip and the compressed file exists
.precompressed_br()offThe same for file.br and Brotli. Preferred over gzip at equal q.
.show_hidden(bool)falseServe 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, ServeDir serves guide/index.html at /guide directly, and /guide/ gets the same page (rewritten, or redirected once to /guide in redirect mode). 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 with ServeDir: /guide becomes /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-Type from the file extension (; charset=utf-8 is added for text), application/octet-stream for unknown ones.
  • Conditional requests. A weak ETag (size and modification time) with If-None-Match, and Last-Modified with If-Modified-Since, both answering 304.
  • Ranges. A single byte range (bytes=a-b, a-, -n) answers 206 with Content-Range, or 416 when unsatisfiable. If-Range is honored by date; since the ETag is weak, an If-Range with an entity tag returns the whole file. Requests with several ranges also get the whole file (200).
  • Precompressed variants chosen from Accept-Encoding q-values, with Content-Encoding, Vary: accept-encoding and an ETag that differs per encoding. The Content-Type comes from the original file.
  • HEAD support with exact Content-Length. Files are streamed in 64 KiB chunks (small ones are read in one go), never buffered whole. Other methods answer 405 with Allow: 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). .env and .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 //host URLs.

Things the framework cannot decide for you:

  • Everything under the root is public. Don't point ServeDir at a directory that mixes public and private files.
  • If users can upload .html or .svg files 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 download Content-Disposition through your own handler. SecurityHeaders already adds X-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.css is served with Cache-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 dev runs Tailwind's watcher alongside the app: add a class in templates/ and open pages reload, then get the rebuilt static/app.css swapped in without another reload (the dev loop).
  • zapline css builds once; zapline css --minify for 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.css moves into styles/app.css, in @layer components, so utility classes can override it.
  • Stylesheets your templates/base.html loaded from a CDN (Pico, in the fullstack template) move into styles/app.css as @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 @source with @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 methodReturns
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:

MethodEffect
.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:

ProblemStatusCode
Not multipart/form-data415multipart_unsupported_media_type
Missing boundary400multipart_missing_boundary
Malformed body400multipart_invalid
Field not in allowed_fields400multipart_unknown_field
Body too large413none
A field exceeds its limit413multipart_field_too_large
Too many fields413multipart_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

CallDoes
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.

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::storage mounts it) only after the HMAC signature and the expiry check out. Anything else is a 403. Files are streamed with the content type and Cache-Control they were stored with (default private, max-age=300), X-Content-Type-Options: nosniff and a sandboxing Content-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 presigned PUT: when the browser reports success, check storage.metadata(&key) and delete what is too large.
  • Local and memory: a PUT to /files/<key>?expires=…&max=…&type=…&sig=…, which App::storage serves. The HMAC signature covers the key, the expiry, the size limit and the type, so none of them can be changed. It answers 403 for a bad or expired signature (download links don't work for uploads, nor the other way round), 415 for another Content-Type, and 413 beyond max_bytes, checked against Content-Length and again while the body streams to disk.

Configuration

Storage::from_env() picks S3 when S3_BUCKET is set, otherwise a local directory:

VariableDefault
STORAGE_DIRstorageLocal directory (created as needed).
STORAGE_SECRETrandomSigns local links.
S3_BUCKETTurns on S3.
S3_REGION / AWS_REGIONus-east-1Use auto for R2.
S3_ENDPOINTAWSFor 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_KEYRequired 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")
}
PluginNeedsServes
telemetry::init(), Telemetry, TraceCargo feature tracing (on by default; adds tracing and tracing-subscriber)nothing: logs and spans
Metricsnothing (always compiled, no extra dependencies)GET /metrics
HealthnothingGET /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();
}
MethodEffect
.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:

FieldValue
http.methodGET, POST, ...
http.routeThe route template (/users/{id}), or unmatched for requests that matched no route
url.pathThe raw path
client.addressThe peer IP address
request_idThe x-request-id request header, or else the response header (as set by SetRequestId)
http.status_code, latency_msRecorded 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:

InstallSeeshttp.route
.plugin(Trace::new()) (recommended)Every request, including 404sThe route template, or unmatched
.wrap(Trace::new())Every request, including 404sNot recorded (routing happens later)
.wrap_route(Trace::new())Matched routes onlyThe 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).

MethodEffect
.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();
}
VariableUse
OTEL_EXPORTER_OTLP_ENDPOINTCollector base URL; traces go to {url}/v1/traces
OTEL_EXPORTER_OTLP_TRACES_ENDPOINTFull traces URL (wins over the above)
OTEL_EXPORTER_OTLP_HEADERSkey=value,key2=value2, e.g. an API key (values may be percent-encoded)
OTEL_SERVICE_NAMEservice.name (default: the executable's name)
OTEL_RESOURCE_ATTRIBUTESExtra resource attributes, deployment.environment=prod,...

What you get:

  • Each request Trace records becomes a server span named like GET /users/{id}, with http.request.method, http.route, url.path, http.response.status_code and client.address. 5xx responses are marked as errors.
  • Your own tracing spans (info_span!("load_user"), #[instrument]) become child spans, and log events inside them become span events.
  • A request with a W3C traceparent header 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
SeriesTypeLabels
http_requests_totalcountermethod, route, status
http_request_duration_secondshistogrammethod, route
http_requests_in_flightgauge
process_start_time_secondsgauge
process_cpu_seconds_total, process_resident_memory_bytes, process_virtual_memory_bytes, process_open_fds, process_max_fdsLinux 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_secondscounters, histogramjob. Only when the app has durable jobs.
scheduled_task_runs_total, scheduled_task_duration_secondscounter, histogramtask (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.

MethodEffect
.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)
}
}
TypeMethods
Counterinc(), add(n), get()
Gaugeset(v), add(v), sub(v), inc(), dec(), get()
Histogramobserve(v), start_timer() (a guard that observes on drop; .stop() returns the seconds, .discard() skips it), count(), sum()
any of themwith_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 (le is 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 Collector or pass a closure Fn(&mut Encoder) to registry.register(..). It writes complete metric families with out.header(name, help, kind) and out.sample(name, &[(label, value)], v).
  • Registry::new() makes a private registry, which you hand to Metrics::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:

EndpointBehavior
GET /health/liveAlways 200 {"status":"up"}. Runs no checks. If this fails, the process is wedged.
GET /health/readyRuns 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 /healthThe 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.

MethodEffect
.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 with Metrics::exclude_paths(["/health/*"]).
  • Rate limiting and authentication middleware installed with App::wrap_route also 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_REUSEPORT listener. 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 to shutdown_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 http2 feature, using h2c prior knowledge and ALPN h2 over 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 http3 feature, 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():

FieldDefaultPurpose
workersCPU countWorker threads
runtimePerCore (Linux)PerCore or MultiThread
header_read_timeout30sSlowloris protection; also closes idle keep-alive connections
body_read_timeout30sA buffered body (Json, Form, String, ...) that arrives slower gets 408 and the connection closes. Streaming bodies set their own pace
keep_alive / pipeline_flushtrue / falseHTTP/1.1 behavior. pipeline_flush batches pipelined responses (see the limitation below)
max_connectionsunlimitedPer worker. Accepting pauses at the limit. WebSockets keep their slot after the upgrade.
max_upgradednonePer worker. A separate limit for WebSockets; upgrades beyond it get 503.
max_header_size, max_headers, max_buf_sizehyper defaultsRequest head limits
http2, http2_max_concurrent_streams, http2_keep_alive_intervaltrue (feature on)HTTP/2 tuning
shutdown_timeout30sDrain deadline
drain_grace1sHow long an idle connection may still send a request once draining starts; its response says connection: close (why)
tls_handshake_timeout10sAbandon slow handshakes
backlog4096Listen backlog
bannertruePrint 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_upgraded to count WebSockets separately. The HTTP connection's slot is then freed at the upgrade, and an upgrade beyond max_upgraded is answered with 503 (ws_too_many_connections, Retry-After: 1) instead of 101, 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_connections slot until its last stream ends, so the socket takes no slot of its own there: one client cannot fill max_connections with streams on a single connection (http2_max_concurrent_streams bounds the streams per connection). With max_upgraded set, it counts against that like any other socket, with the same 503 when 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. A recv loop then sees the close handshake and ends; a send afterwards fails with ws_closed. The server waits for the callbacks to return, up to shutdown_timeout, and then drops the ones still running (the socket closes). Upgrades that arrive during the drain get 503 (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 http3 feature is not part of full, and its API may change in any release. It is built on the pre-1.0 h3 crate 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 methodDefaultPurpose
port(u16)the TCP portUDP port to listen on
advertise(bool)trueSend Alt-Svc on HTTP/1.1 and HTTP/2 responses. Turn it off when a CDN advertises for you.
alt_svc_max_age(Duration)24hThe ma parameter: how long clients remember the advertisement
max_concurrent_streams(u32)100Concurrent requests per QUIC connection
max_idle_timeout(Duration)30sClose connections with no traffic for this long

How it behaves:

  • Requests have version() == Version::HTTP_3 and ConnInfo::secure == true. Request bodies stream, so the body limit, Json<T> and BodyStream work as over TCP. Responses stream too (SSE, StreamBody, trailers), and Content-Length is set when the body size is known.
  • max_header_size limits the HTTP/3 header section, header_read_timeout bounds how long a request stream may take to send its headers, tls_handshake_timeout bounds the QUIC handshake, and max_connections caps 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. After shutdown_timeout the remaining connections are closed.
  • With App::run, HTTP/3 runs on its own runtime (workers threads, named zapline-h3). With App::serve, it runs on your runtime and binds UDP on the listener's address. App::run_unix with HTTP/3 fails with InvalidInput.
  • 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_flush on (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 test hyper_drops_automatic_error_responses_with_pipeline_flush fails 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::run the 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/.br files 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:

  1. ssh host uname -sm picks the build: a plain cargo build --release when this machine has the same OS and CPU, otherwise an error that says what to use. --static builds for <cpu>-unknown-linux-musl, which runs on any Linux. It uses musl-gcc (apt install musl-tools), a <cpu>-linux-musl-gcc cross compiler, or cargo zigbuild, whichever is installed, and says what to install when none is. --target <triple> picks the target yourself.

  2. With styles/app.css, Tailwind builds the minified static/app.css first.

  3. scp uploads the binary, its SHA-256, a systemd unit, the settings file and the install script to a private directory in /tmp.

  4. The script installs and swaps:

    First run onlyEvery run
    A system user named after the appThe binary in /opt/<app>/releases/<id>/, checked against its SHA-256
    /etc/<app>/env with PORT, APP_ENV=production, the database URL (SQLite in /var/lib/<app>/), and a generated value for each *_SECRET/*_KEY in .env.example--env KEY=VALUE settings merged into /etc/<app>/env
    net.ipv4.tcp_migrate_req=1 (see below)/etc/systemd/system/<app>@.service rewritten

    It then runs systemctl start <app>@<id> next to the running release. Both bind the same port (SO_REUSEPORT). systemctl start returns only once the new process has answered its own health check (/health/ready with the Health plugin, otherwise /; --health picks another path) and said READY=1. The script checks the port with curl, points /opt/<app>/current at 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_PATH set, the server requests that path from itself, in-process, before it binds the port. It retries until the answer is below 400, for up to ZAPLINE_READY_TIMEOUT seconds (default 30; the unit sets 60). A 404 fails at once. With NOTIFY_SOCKET set (systemd Type=notify), it sends READY=1 once it is listening and STOPPING=1 when it starts to drain.
  • Two processes share the port. ZAPLINE_REUSE_PORT=1 makes every listener use SO_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+, which zapline ship turns 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 says connection: 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 hasThe config gets
A SQLite database (the fullstack default)A volume at /home/app/data (Fly mount, Render disk, Compose volume)
zapline's postgres featureA Postgres service with DATABASE_URL wired to it (Compose, Render)
zapline's redis featureA Redis service with REDIS_URL (Compose)
The Health pluginHealth 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:

PieceShared through
Sessions with App::database_sessionsThe zapline_sessions table
Durable jobsThe zapline_jobs table (FOR UPDATE SKIP LOCKED on Postgres, so instances share one queue)
Uploaded files with Storage::s3The 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
}
PieceWith RedisIf Redis is down
Cache::redisEntries 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.
RedisStoreSessions 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):

ParameterMeaning
ca_fileA PEM file of CA certificates to trust instead of the Mozilla roots: your private CA, or a self-signed server certificate
sniThe 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_fileA 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:

testper-worker poolper-worker pool + pingApp::database pool
plaintext (16 pipelined)859,217
json87,065
db17,27613,04112,602
queries (20)1,043977960
fortunes17,03214,08914,971
updates (20)808752845
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::database pool: the canonical App::database + Db path: 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):

appcratescold checkcold buildrebuild after editing a handlercheck after editing a handlerdebug binary
zapline, minimal10819.0 s27.2 s0.6 s0.3 s60 MB
axum, minimal4810.9 s14.6 s1.4 s0.2 s22 MB
actix-web, minimal14625.7 s34.5 s1.8 s0.3 s78 MB
zapline, api10816.7 s26.1 s1.3 s0.5 s68 MB
axum, api9516.7 s23.5 s2.5 s0.4 s50 MB
actix-web, api15931.2 s40.2 s2.9 s0.5 s95 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

  1. 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.
  2. Core-local reference counts. Each worker holds its own Arc handles to app data. Per-request clones only touch the current core's cache lines.
  3. 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 &'static pointer.
  4. One allocation per routed request. The only allocation is the boxed handler future. A 404 or 405 allocates nothing.
  5. Pipelining. Responses to pipelined HTTP/1.1 requests are flushed together.
  6. Build-time work. OpenAPI specs, metrics series and security header values are computed once at startup, not on each request.

Tuning

KnobDefaultNotes
App::workers(n)CPU countOne runtime per core
ServerConfig::runtimePerCore on Linux and FreeBSDUse MultiThread when handlers share heavy I/O resources and work-stealing helps
ServerConfig::header_read_timeout30sSlowloris protection. It costs a timer per request. None disables it.
App::body_limit2 MiB
Allocatorsystemmimalloc 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 Arc and avoid global locks on the request path. Prefer sharded or lock-free structures.
  • Use wrap_route instead of wrap for middleware that only needs matched routes. That way it doesn't run on 404s.
  • Return Bytes or &'static str for static payloads. Both are zero-copy.
  • Run blocking work with tokio::task::spawn_blocking.
  • For database-heavy endpoints, measure the pool. App::database keeps 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.rs shows a per-worker pool built from sqlx's PoolOptions behind 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

CheckWhat it doesCommand
Test suiteAbout 1,900 tests: in-process and real-socket integration tests for every module, CLI and code generator./scripts/check.sh
Chaos suiteStarts a real server and attacks it over real sockets (scenarios below)./bench/chaos/run.sh
SoakMixed keep-alive load for as long as you like, sampling memory to find leaks./bench/chaos/run.sh soak soak=3600
FuzzingCoverage-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 failscargo +nightly fuzz run request
Real deploymentzapline ship to a systemd server over SSH, under loadsee below
Supply chainRustSec advisories, licenses, crate sourcescargo deny check, cargo audit
No unsafe#![forbid(unsafe_code)] in every crate: the compiler rejects any unsafe blockbuilt 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.

ScenarioResult
5,000 connections of random bytesNo 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 timeAll cut at header_read_timeout; other clients answered in under 1 ms meanwhile
500 idle keep-alive connectionsAll closed by the server at the timeout
200 request bodies sent at 2 bytes/s408 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 fields413 before the body is read; huge forms handled
2,000 panicking handlersServer stays up (500s with CatchPanic)
600 connections against a 256 file-descriptor limitAccept loop backs off (1% CPU), recovers when descriptors free up
HTTP/2 rapid reset (CVE-2023-44487), PING flood, SETTINGS floodGOAWAY after a few thousand resets, memory flat
WebSocket frame claiming 2^62 bytes; 2,000 open sockets; 50,000 echoesClosed at once; 23 MiB for 2,000 sockets
Soak: 128 keep-alive connections, mixed routes10 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).
  • --rollback under 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) answers 408.
  • Malformed requests got a dropped connection instead of a 4xx because pipeline_flush was 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 ship regenerated 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 through sudo.

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_timeout covers buffered bodies; BodyStream and Multipart handlers should bound their own uploads.
  • Young code, small team. Bugs like the zapline ship settings 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

LinuxmacOSWindows
Framework, all Cargo features✅✅✅
Default server runtimethread-per-core (SO_REUSEPORT)multi-threaded (work-stealing)multi-threaded (work-stealing)
HTTP/1.1, HTTP/2, TLS✅✅✅
Graceful shutdown onSIGINT, SIGTERMSIGINT, SIGTERMCtrl-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 restartSIGTERM, then kill after 5sSIGTERM, then kill after 5simmediate stop
zapline benchwrk or ohawrk or ohaoha
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

  1. Install the Visual Studio Build Tools with the "Desktop development with C++" workload. Rust uses its linker, and two dependencies (ring for TLS, zstd for compression) compile C code. rustup-init.exe offers to install it for you.
  2. Install Rust from https://rustup.rs (rustup-init.exe). The default x86_64-pc-windows-msvc toolchain is the one CI tests.
  3. 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_REUSEADDR on 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_timeout below that, for example with .shutdown_timeout(Duration::from_secs(4)).
  • Static files: besides the protections on every platform (.., encoded separators, symlink escapes), ServeDir rejects:
    • 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 open secret.txt)
  • Line endings: .gitattributes keeps 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.

FeatureDefaultEnablesImpliesDependencies it adds
macrosyesThe route attribute macros #[get], #[post], #[put], #[patch], #[delete], #[head], #[options] and #[route], with their OpenAPI arguments (Routing, OpenAPI)zapline-macros (syn, quote, proc-macro2)
jsonyesJson<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
queryyesThe Query<T> extractorserde_urlencoded
formyesThe Form<T> extractor and the test client's .form()serde_urlencoded
openapiyeszapline::openapi: the OpenApi plugin, Swagger UI, Scalar and Redoc, the builder methods, JsonSchema, and typed clients (client_js, codegen) (OpenAPI, Typed frontends)jsonschemars, zapline-codegen
validateyeszapline::validate: Valid<T> and the 422 error format (Validation)jsonvalidator (with derive)
configyeszapline::config: Config, Secret<T> and App::config (Configuration)toml
tracingyeszapline::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)
http2fullHTTP/2 support on the server (Server)hyper/http2, hyper-util (http2, server-auto)
tlsfullzapline::tls: TLS with rustls (Server)tokio-rustls (ring, tls12)
http3Experimental. zapline::http3: HTTP/3 over QUIC next to HTTPS, advertised with Alt-Svc (Server)tlsquinn (rustls-ring), h3, h3-quinn
wsfullzapline::ws: WebSockets (WebSockets)tokio-tungstenite (handshake)
fsfullzapline::fs: ServeDir, ServeFile and App::static_files (Static files)mime_guess, tokio-util (io)
multipartfullzapline::multipart: the Multipart extractor (Uploads)multer
compressionfullmiddleware::Compression and CompressionLevel (Hardening)async-compression (gzip, brotli, zstd, deflate, zlib), tokio-util
jwtfullJSON Web Tokens: JwtConfig, Claims, Jwt<C>, JwtAuthenticator, Authenticate::jwt() and App::jwt; key rings with JwtKey (rotate, retire) and App::serve_jwks (Authentication)jsonjsonwebtoken (aws-lc backend, PEM keys)
jwksfullJwks: 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)jwthyper-rustls (hyper client, rustls)
cookiesfullzapline::cookies (CookieJar, signed and private jars, Key), zapline::session (SessionLayer, Session) and auth::{Csrf, CsrfToken} (Cookies, sessions & CSRF)jsoncookie (percent-encode, signed, private), getrandom, hmac, sha2 (signed CSRF tokens)
viewsfullzapline::view: Views, App::views, HxRequest, Hx (Views & htmx)minijinja (loader)
passwordfullzapline::auth::password: Argon2id hash and verify (Authentication)argon2, getrandom
mailfullzapline::mail: Mailer, Email, the dev inbox (Email)lettre (SMTP, rustls)
oauthfullzapline::auth::oauth: OAuth (GitHub, Google, custom), App::oauth, OAuthLogin (Social login)jsonhyper-rustls (hyper client, rustls), sha2, getrandom
i18nfullzapline::i18n: I18n (TOML files per language), App::i18n, the Locale extractor, plurals, t(...) in templates (Translations)toml
redisfullzapline::redis: a built-in Redis client (Redis, App::redis), Cache::redis, Live::redis, RateLimit::redis, RedisStore for sessions (Running several instances)json
redis-tlsfullrediss:// 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)redistokio-rustls, webpki-roots
otlpfullOpenTelemetry trace export over OTLP/HTTP: Telemetry::otlp, or just OTEL_EXPORTER_OTLP_ENDPOINT (Observability)tracing, jsonhyper-rustls (hyper client, rustls)
tzfullIANA time zones (CRON_TZ=Europe/Paris) in cron schedules. UTC and fixed offsets work without it.jiff (system tz database; bundled on Windows)
storagefullzapline::storage: Storage (local directory, memory), signed links, App::storage (File storage)mime_guess, hmac, sha2, getrandom
s3Storage::s3 and S3: S3, R2, MinIO and other S3-compatible services (File storage)storagehyper-rustls (hyper client, rustls)
cachefullzapline::cache: Cache (get_or_set without stampedes), ResponseCache, ETag (Caching)json
postgreszapline::db for Postgres: Database, Db, DbCheck, migrations (Databases)sqlx (postgres, runtime-tokio, migrate, rustls)
sqlitezapline::db for SQLite, including Database::sqlite_memory() for tests (Databases)sqlx (sqlite, runtime-tokio, migrate)
chronofullzapline::chrono (NaiveDate, DateTime<Utc>) with sqlx, serde and OpenAPI support; extract::datetime for datetime-local inputs (Databases)chrono
uuidfullzapline::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:

ModuleWhat it provides
CoreRouting, Path, State, Extension, Bytes and String extractors, responses, errors, the test client, startup/shutdown hooks and plugins
middlewareLogger, Cors, Timeout, DefaultHeaders, CatchPanic, ConcurrencyLimit, NormalizePath, RateLimit, SetRequestId, SecurityHeaders
authBearerToken, BasicAuth, ApiKey, Authenticate, Guard, route policies (JWT and CSRF need their own features)
diDep<T>, Cached<T>, Inject and the provide* methods
tasksBackgroundTasks, App::schedule, App::background
sse, streamServer-Sent Events, StreamBody and BodyStream (Ndjson needs json)
metrics, healthThe Prometheus Metrics plugin and the Health plugin

Choosing features

  • A typical JSON API: the defaults. Add jwt and compression when you need them.
  • A web app with server-rendered pages: cookies (sessions, CSRF) and fs (assets), plus multipart for uploads.
  • Realtime features: ws. Server-Sent Events need no feature.
  • A slim microservice: default-features = false, features = ["json", "macros"] and add config or tracing if you use them. Types and macros behind a missing feature are not compiled, so the compiler tells you when you reach for one, and zapline check names 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

  1. 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.
  2. FastAPI-level ergonomics. The common case is one line, errors are zapline::Error, and ? works everywhere.
  3. Enterprise-grade control. Every default can be overridden, through builders for configuration and traits for behavior (RateLimitKey, SessionStore, HealthCheck, Authenticator).
  4. No unsafe outside 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.
  5. 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 thisUse
Bundle routes, middleware, state and hooksPlugin and App::plugin (any FnOnce(App) -> App is a plugin)
Inspect every route after the table is final, and add routesApp::on_build. This is how /openapi.json is generated once at startup.
Initialize resources before accepting connectionsApp::on_startup, with Resources behind State<T> and Dep<T>
Attach typed data to routesRoutes::meta, RouteInfo::meta::<T>(). Auth policies, rate limits and OpenAPI docs all work this way.
Re-render every errorApp::error_handler with ErrorReport
Write extractors and responsesFromRequestParts, FromRequest, IntoResponse

Where each module is documented

AreaModulesChapter
Coreapp, router, request, response, extract, error, lifecycle, testRouting, Extractors, Responses, Errors, Lifecycle, Testing
Serverserver, tls, http3Server
Middlewaremiddleware/*Middleware, Hardening middleware
API docs and validationopenapi/*, validate, zapline-macrosOpenAPI, Validation
Application structuredi, config, tasksDependency injection, Configuration, Background tasks
Securityauth/*, cookies, sessionAuthentication, Cookies, sessions & CSRF
Realtime and contentws, sse, stream, fs, multipartWebSockets, Streaming, Static files & uploads
Observabilitytelemetry, metrics, healthObservability
Toolingzapline-cliProject 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

  1. Conn::on_read_head_error (src/proto/h1/conn.rs, line 339) calls on_parse_error (line 836), which queues the error head with write_head and stores the error in state.error. The malformed bytes stay in the read buffer (consume_leading_lines only strips leading CRLFs).
  2. Buffered::poll_flush (src/proto/h1/io.rs, line 278) returns Ready(Ok(())) without writing while flush_pipeline && !read_buf.is_empty(), on the assumption that more pipelined requests are about to be parsed and their responses batched.
  3. The read side is already closed, so nothing is parsed or flushed again. Dispatcher::poll_inner (src/proto/h1/dispatch.rs, line 143) sees is_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.