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

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)