Cheatsheet
One runnable file showing the canonical way to do each common task: models with validation, handlers, errors, dependency injection, middleware, route groups, OpenAPI, health checks and in-process tests. When there are several ways to do something, this is the one the guide, the CLI generators and AGENTS.md use.
//! The zapline cheatsheet: one file, the canonical way to do each common
//! task. It is included verbatim in the guide (`docs/src/cheatsheet.md`) and
//! in `llms.txt`, so it doubles as compact context for AI assistants.
//!
//! cargo run -p zapline-examples --example cheatsheet
//! cargo test -p zapline-examples --example cheatsheet
use std::collections::BTreeMap;
use std::sync::{Arc, RwLock};
use zapline::middleware::{Cors, SetRequestId};
use zapline::prelude::*;
// ---- Models: `#[model]` derives serde, JsonSchema (OpenAPI) and, with `#[validate]` rules, Validate.
// Doc comments become OpenAPI descriptions.
/// A note.
#[model]
pub struct Note {
pub id: u64,
pub text: String,
}
/// Body of `POST /notes`.
#[model]
pub struct NewNote {
#[validate(length(min = 1, max = 500))]
pub text: String,
}
/// Query string of `GET /notes`.
#[model]
pub struct Page {
pub limit: Option<usize>,
}
// ---- Services: a trait behind `Dep<dyn Trait>` so tests can swap it.
pub trait Notes: Send + Sync {
fn list(&self, limit: usize) -> Vec<Note>;
fn get(&self, id: u64) -> Option<Note>;
fn add(&self, text: String) -> Note;
}
#[derive(Default)]
pub struct MemNotes(RwLock<BTreeMap<u64, Note>>);
impl Notes for MemNotes {
fn list(&self, limit: usize) -> Vec<Note> {
self.0
.read()
.unwrap()
.values()
.take(limit)
.cloned()
.collect()
}
fn get(&self, id: u64) -> Option<Note> {
self.0.read().unwrap().get(&id).cloned()
}
fn add(&self, text: String) -> Note {
let mut map = self.0.write().unwrap();
let note = Note {
id: map.len() as u64 + 1,
text,
};
map.insert(note.id, note.clone());
note
}
}
// ---- Handlers: route macro + async fn; arguments are extractors.
/// List notes.
#[get("/notes", tag = "notes")]
async fn list_notes(notes: Dep<dyn Notes>, Query(page): Query<Page>) -> Json<Vec<Note>> {
Json(notes.list(page.limit.unwrap_or(50).min(200)))
}
/// Fetch one note. Unknown ids are a 404 with a stable error code.
#[get("/notes/{id}", tag = "notes")]
async fn get_note(notes: Dep<dyn Notes>, Path(id): Path<u64>) -> Result<Json<Note>> {
let note = notes
.get(id)
.ok_or_else(|| Error::not_found().with_code("note_not_found"))?;
Ok(Json(note))
}
/// Create a note. Invalid bodies get a 422 with per-field details.
#[post("/notes", tag = "notes", status = 201)]
async fn create_note(
notes: Dep<dyn Notes>,
Valid(Json(new)): Valid<Json<NewNote>>,
) -> (StatusCode, Json<Note>) {
(StatusCode::CREATED, Json(notes.add(new.text)))
}
// ---- Middleware: a plain async fn.
async fn powered_by(req: Request, next: Next) -> Response {
let mut res = next.run(req).await;
res.headers_mut()
.insert("x-powered-by", HeaderValue::from_static("zapline"));
res
}
// ---- The app: one function that tests and `main` share.
pub fn app(notes: Arc<dyn Notes>) -> App {
App::new()
.provide_dyn::<dyn Notes>(notes)
.wrap(SetRequestId::new())
.wrap(Cors::permissive())
.wrap(powered_by)
.nest(
"/api",
Routes::new().mount((list_notes, get_note, create_note)),
)
.plugin(Health::new()) // /health, /health/live, /health/ready
.openapi(OpenApi::new("Notes", "1.0.0")) // /openapi.json + /docs
}
fn main() -> std::io::Result<()> {
app(Arc::new(MemNotes::default())).run("0.0.0.0:8080")
}
// ---- Tests: in-process, no sockets.
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[tokio::test]
async fn create_then_fetch() {
let client = app(Arc::new(MemNotes::default())).test();
let res = client
.post("/api/notes")
.json(&json!({"text": "hi"}))
.send()
.await;
assert_eq!(res.status(), 201);
let note: Note = res.json();
let res = client.get(&format!("/api/notes/{}", note.id)).send().await;
assert_eq!(res.status(), 200);
assert_eq!(res.header("x-powered-by"), Some("zapline"));
}
#[tokio::test]
async fn validation_and_not_found() {
let client = app(Arc::new(MemNotes::default())).test();
let res = client
.post("/api/notes")
.json(&json!({"text": ""}))
.send()
.await;
assert_eq!(res.status(), 422);
let res = client.get("/api/notes/99").send().await;
assert_eq!(res.status(), 404);
}
}
Viewing this on GitHub? The code block above only renders on the docs site; the file itself is examples/examples/cheatsheet.rs.
Run it with cargo run -p zapline-examples --example cheatsheet and open http://localhost:8080/docs.
Everything else in one line each
| Task | How |
|---|---|
| Typed path params | Path(id): Path<u64>, Path((org, repo)): Path<(String, String)> |
| Query string / form | Query(q): Query<T>, Form(f): Form<T> |
| Validated input | Valid(Json(body)): Valid<Json<T>> (also Valid<Query<T>>, Valid<Form<T>>) |
| Plain shared state | App::state(value) + State(v): State<T> |
| Services / DI | App::provide(value) or provide_dyn::<dyn Trait>(arc) + Dep<T> |
| Errors | Error::bad_request(msg), not_found(), conflict(msg), unauthorized(), forbidden(), .with_code("code") |
| Domain errors | impl From<MyError> for zapline::Error, then ? |
| HTML / redirect | Html(string), Redirect::to("/path") |
| Route group + prefix | App::nest("/api", Routes::new().mount((a, b))) |
| Group middleware / auth | Routes::wrap(mw), .require_roles(["admin"]), .wrap(Authenticate::jwt()) |
| Config | Config::load()? reads config/default.toml, config/{profile}.toml, APP__* env and .env |
| Startup / shutdown | App::on_startup(|res| async move { res.insert(pool); Ok(()) }) (then State<Pool>), App::on_shutdown(..) |
| Background work | BackgroundTasks extractor, App::schedule(Schedule::every(dur), job), Schedule::cron("0 9 * * MON-FRI"); once per cluster: .jobs(|q| q.cron("@daily", MyJob {})) |
| Live updates | App::live(Live::new()) + live: Live; live.publish("topic", "event", html); handler returns live.sse("topic").into_response(); htmx: hx-ext="sse" sse-connect="/live/topic" sse-swap="event" (Live updates) |
| Optional features | zapline add ws redis turns them on in Cargo.toml; zapline add --list shows them all. A missing one shows up in zapline check as hint: enable the `ws` feature: zapline add ws (Feature flags) |
| Realtime | WebSocketUpgrade (ws feature), Sse |
| One binary + deploy | zapline::embed!("templates") wherever a dir goes (disk in debug); zapline ship user@host (Deployment) |
| Static files | App::assets("/static", "static") (fs feature): hashed URLs cached forever, {{ asset('app.css') }} in templates, assets: Assets + assets.url("app.css") in handlers; plain ServeDir::new("public") for anything else |
| Database | App::database(Database::from_env()) + db: Db, then sqlx::query_as(..).fetch_one(&*db) (Databases) |
| List endpoints | params: ListParams + Listing::new("posts", "id, title").sortable(["title"]).search(["title"]).filter_bool("done").fetch(&db, ¶ms).await? returns Paged<T> (?limit=&offset=&sort=-id&q=&done=true) |
| Migrations | zapline db new create_posts (up + down SQL), applied at startup or by db migrate; db status/rollback/redo/reset |
| Test data / seeds | Factory::new("posts").set("title", "Hi").create(&pool).await? fills the other required columns (and parent rows) from the schema; .create_many(&pool, 10); record .int("id"), .json(), .parse::<Post>(); App::seed(|db| async move { .. }) + zapline db seed |
| HTML + htmx | App::views("templates") + views: Views, hx: HxRequest, then views.page_or_block(&hx, "page.html", "block", context!{..}) (Views) |
| Admin panel | App::admin(|admin| admin): every table at /admin (debug + localhost); production: .protect(middleware); .exclude([..]), .hide(["users.email"]), .read_only() (Admin panel) |
| Translations | App::i18n(I18n::load("locales")?) (locales/en.toml: hello = "Hi, {name}!") + t: Locale, then t.t_with("hello", &[("name", &n)]), t.plural("items", n, &[]); templates: context! { t }, {{ t("hello", name=n) }}; language from ?lang=, cookie, Accept-Language (Translations) |
| Several instances | let redis = Redis::from_env()? (REDIS_URL); Cache::redis(redis.clone()), Live::new().redis(..), RateLimit::per_ip(..).redis(..), SessionLayer::new(RedisStore::new(..)); redis.cmd(["INCR", "k"]).await?.int(); TLS: feature redis-tls, rediss://...?ca_file=ca.pem&sni=name (Several instances, over TLS) |
| OpenTelemetry | feature otlp, then OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 (or Telemetry::new().otlp(url)); requests become spans, traceparent is continued (Observability) |
| Caching | App::cache(Cache::memory()) + cache: Cache, then cache.get_or_set("key", ttl, || async { Ok(v) }); pages: Routes::wrap(ResponseCache::new(ttl)); App::wrap(ETag) (Caching) |
| Files | App::storage(Storage::from_env()?) + storage: Storage: put, put_with(k, b, PutOptions), get, get_stream/put_stream, list(prefix), metadata, copy, delete, url(key, ttl), upload_url (browser to S3) (File storage) |
mail.send(Email::to(a).cc(b).bcc(c).attach(name, type, bytes)); .template(&views, "mail/x", ctx); /_dev/mail; Mailer::outbox().sent() (Email) | |
| Dev loop | zapline dev: templates, translations and static files reload without a restart, CSS swaps in place, Rust changes swap with no downtime, compile errors show in the browser (The dev loop) |
| Dev console | App::dev_console(): routes, requests, errors (with ZL codes and fixes), jobs, mail at /_dev (debug, localhost only) |
| Typed frontend | a frontend/ dir: zapline dev rewrites frontend/src/api.ts after each build (api.createTask(body), isValidationError(e, "createTask") && e.fields.title); no build step: OpenApi::new(..).client_js() + import { api } from "/_zapline/api.js" (Typed frontends) |
| Metrics | .plugin(Metrics::new()) serves Prometheus /metrics |
| Tests | app().test().get("/x").bearer(token).send().await then .status(), .json::<T>(), .text() |
| Fuzz every route | app().test().fuzz().await.assert_clean() or zapline fuzz |
| Tests from traffic | zapline dev --record, use the app, zapline snap signup; replayed by app().test().replay_snapshots("tests/snapshots") |
| CLI | zapline new <app> --template fullstack, dev, generate resource post title body:text due:date author:references (migration with timestamps, JSON API with PATCH, index/show/edit pages, tests), g migration add_x_to_posts x:int=0 (.up.sql + .down.sql, and the posts resource's structs, queries, pages and test follow), g auth (accounts, password reset, GitHub/Google login), g job <name> (durable job), g mailer <name> (templated email + test), g tailwind + css (Tailwind, no Node), g deploy fly|railway|render|compose, db new/migrate/status/rollback/redo/reset, routes --json, openapi, fuzz, client (typed TS/JS client; dev keeps frontend/src/api.ts current), snap <name>, check, test, explain ZL0002, mcp (MCP server for AI assistants) |
| HTML forms | #[serde(default, deserialize_with = "zapline::extract::empty_as_none")] for optional inputs, zapline::extract::checkbox for checkboxes |
| Form errors + flash | ValidForm(form): ValidForm<T>; Err(invalid) => return views.render_invalid("new.html", context!{}, invalid) (422 with {{ errors.x }}, {{ old.x }}); flash: Flash, flash.success("Saved"); Ok(flash.redirect("/posts")), {% for m in flash %} in templates (Views) |