| 1 | --- |
| 2 | name: rust-crud |
| 3 | description: Scaffold a Rust CRUD web application with Axum, SQLite, Askama templates, API key auth, embedded static assets, and Docker deployment. Use when the user wants to build a new Rust web server with CRUD operations. |
| 4 | --- |
| 5 | |
| 6 | # Rust CRUD Web App |
| 7 | |
| 8 | ## Overview |
| 9 | |
| 10 | Scaffold and build Rust CRUD web applications using Axum + SQLite + Askama templates. The result is a single binary web server with HTML pages, a JSON API, optional API key auth, and Docker deployment. |
| 11 | |
| 12 | ## Project Structure |
| 13 | |
| 14 | ``` |
| 15 | project-name/ |
| 16 | ├── src/ |
| 17 | │ ├── main.rs # Entry point, starts the server |
| 18 | │ ├── server.rs # Axum routes, middleware, handlers, static asset serving |
| 19 | │ ├── db.rs # SQLite schema, CRUD functions, error types |
| 20 | │ └── auth.rs # Session/cookie auth (when using login-based auth) |
| 21 | ├── templates/ # Askama HTML templates |
| 22 | │ ├── base.html # Base layout with blocks (title, content) |
| 23 | │ └── *.html # Pages extend base.html |
| 24 | ├── static/ # CSS, fonts, favicons (embedded via rust_embed or served via tower-http) |
| 25 | │ └── styles.css |
| 26 | ├── assets/ # Favicons, fonts, images (embedded via rust_embed) |
| 27 | ├── .env.example # Environment variable reference |
| 28 | ├── Dockerfile # Multi-stage build |
| 29 | └── docker-compose.yml # Compose config with volume for DB persistence |
| 30 | ``` |
| 31 | |
| 32 | ## Dependencies (Cargo.toml) |
| 33 | |
| 34 | Use these exact crates and features: |
| 35 | |
| 36 | ```toml |
| 37 | [dependencies] |
| 38 | axum = "0.8" |
| 39 | tokio = { version = "1", features = ["full"] } |
| 40 | askama = "0.15" |
| 41 | askama_web = { version = "0.15", features = ["axum-0.8"] } |
| 42 | rusqlite = { version = "0.38", features = ["bundled"] } |
| 43 | serde = { version = "1", features = ["derive"] } |
| 44 | serde_json = "1" |
| 45 | nanoid = "0.4.0" |
| 46 | rust-embed = "8" |
| 47 | dotenvy = "0.15" |
| 48 | subtle = "2" |
| 49 | tracing = "0.1" |
| 50 | tracing-subscriber = "0.3" |
| 51 | rand = "0.8" |
| 52 | tower-http = { version = "0.6", features = ["fs"] } |
| 53 | ``` |
| 54 | |
| 55 | Only add additional crates when the specific app requires them. Do NOT include TUI crates (ratatui, crossterm), CLI crates (clap), or HTTP client crates (reqwest) unless explicitly requested. |
| 56 | |
| 57 | - `tracing` + `tracing-subscriber` — structured logging, always include |
| 58 | - `rand` — needed for session token generation when using session-based auth |
| 59 | - `tower-http` — alternative to `rust-embed` for serving static files from disk (simpler during development, no recompile on asset changes) |
| 60 | |
| 61 | ## Database Layer (db.rs) |
| 62 | |
| 63 | Pattern: single-file module with `Arc<Mutex<Connection>>` for thread-safe SQLite access. |
| 64 | |
| 65 | ### Structure |
| 66 | |
| 67 | ```rust |
| 68 | use nanoid::nanoid; |
| 69 | use rusqlite::{Connection, params}; |
| 70 | use serde::{Deserialize, Serialize}; |
| 71 | use std::fmt; |
| 72 | use std::sync::{Arc, Mutex}; |
| 73 | |
| 74 | pub type Db = Arc<Mutex<Connection>>; |
| 75 | |
| 76 | #[derive(Debug)] |
| 77 | pub enum DbError { |
| 78 | Sqlite(rusqlite::Error), |
| 79 | LockPoisoned, |
| 80 | } |
| 81 | |
| 82 | impl fmt::Display for DbError { |
| 83 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 84 | match self { |
| 85 | DbError::Sqlite(e) => write!(f, "Database error: {}", e), |
| 86 | DbError::LockPoisoned => write!(f, "Database lock poisoned"), |
| 87 | } |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | impl std::error::Error for DbError {} |
| 92 | |
| 93 | impl From<rusqlite::Error> for DbError { |
| 94 | fn from(e: rusqlite::Error) -> Self { |
| 95 | DbError::Sqlite(e) |
| 96 | } |
| 97 | } |
| 98 | ``` |
| 99 | |
| 100 | ### Key patterns |
| 101 | |
| 102 | - **Model struct**: derive `Serialize, Deserialize`, all fields `pub` |
| 103 | - **ID generation**: `nanoid!(10)` for short unique IDs |
| 104 | - **DB path from env**: `std::env::var("APP_DB_PATH").unwrap_or_else(|_| "app.sqlite".to_string())` |
| 105 | - **init_db()**: opens connection, runs `CREATE TABLE IF NOT EXISTS`, returns `Arc<Mutex<Connection>>` |
| 106 | - **CRUD functions**: standalone functions that take `&Db` as first param |
| 107 | - `create_*` — INSERT, return created model with `last_insert_rowid()` |
| 108 | - `get_*_by_short_id` — SELECT, return `Result<Option<Model>, DbError>` |
| 109 | - `get_all_*` — SELECT with ORDER BY id DESC |
| 110 | - `delete_*_by_short_id` — DELETE, return `Result<bool, DbError>` (rows_affected > 0) |
| 111 | - `update_*_by_short_id` — UPDATE then SELECT to return updated model |
| 112 | - **Error handling**: `QueryReturnedNoRows` maps to `Ok(None)`, not an error |
| 113 | |
| 114 | ## Server Layer (server.rs) |
| 115 | |
| 116 | ### Embedded assets with rust_embed |
| 117 | |
| 118 | ```rust |
| 119 | use rust_embed::Embed; |
| 120 | |
| 121 | #[derive(Embed)] |
| 122 | #[folder = "assets/"] |
| 123 | struct Assets; |
| 124 | |
| 125 | #[derive(Embed)] |
| 126 | #[folder = "static/"] |
| 127 | struct Static; |
| 128 | ``` |
| 129 | |
| 130 | Serve with handlers that match on file path and return correct MIME types: |
| 131 | |
| 132 | ```rust |
| 133 | fn mime_from_path(path: &str) -> &'static str { |
| 134 | match path.rsplit('.').next().unwrap_or("") { |
| 135 | "css" => "text/css", |
| 136 | "js" => "application/javascript", |
| 137 | "html" => "text/html", |
| 138 | "png" => "image/png", |
| 139 | "ico" => "image/x-icon", |
| 140 | "svg" => "image/svg+xml", |
| 141 | "woff" | "woff2" => "font/woff2", |
| 142 | "ttf" => "font/ttf", |
| 143 | "otf" => "font/otf", |
| 144 | "json" | "webmanifest" => "application/json", |
| 145 | _ => "application/octet-stream", |
| 146 | } |
| 147 | } |
| 148 | ``` |
| 149 | |
| 150 | ### App state |
| 151 | |
| 152 | ```rust |
| 153 | #[derive(Clone)] |
| 154 | struct AppState { |
| 155 | db: Db, |
| 156 | server_config: ServerConfig, |
| 157 | } |
| 158 | ``` |
| 159 | |
| 160 | Add domain-specific fields as needed (e.g., a highlighter, cache, etc). |
| 161 | |
| 162 | ### Askama templates |
| 163 | |
| 164 | ```rust |
| 165 | use askama::Template; |
| 166 | use askama_web::WebTemplate; |
| 167 | |
| 168 | #[derive(Template)] |
| 169 | #[template(path = "index.html")] |
| 170 | struct IndexTemplate; |
| 171 | |
| 172 | // Templates with data: |
| 173 | #[derive(Template)] |
| 174 | #[template(path = "item.html")] |
| 175 | struct ItemTemplate { |
| 176 | name: String, |
| 177 | content: String, |
| 178 | } |
| 179 | ``` |
| 180 | |
| 181 | Render with `WebTemplate(MyTemplate { ... })`. |
| 182 | |
| 183 | ### Route structure |
| 184 | |
| 185 | Two sets of routes: **web routes** (HTML pages + form submissions) and **API routes** (JSON). |
| 186 | |
| 187 | **Web routes:** |
| 188 | - `GET /` — index page (template) |
| 189 | - `GET /admin` — admin panel (template) |
| 190 | - `POST /items` — form submission, redirects on success |
| 191 | - `GET /items/{short_id}` — view single item (template) |
| 192 | |
| 193 | **API routes:** |
| 194 | - `GET /api/items` — list all (JSON) |
| 195 | - `POST /api/items` — create (JSON body → 201 + JSON) |
| 196 | - `GET /api/items/{short_id}` — get one (JSON) |
| 197 | - `PUT /api/items/{short_id}` — update (JSON body → JSON) |
| 198 | - `DELETE /api/items/{short_id}` — delete (JSON) |
| 199 | |
| 200 | **Static asset routes:** |
| 201 | - `GET /assets/{*path}` — embedded assets (favicons, fonts, images) |
| 202 | - `GET /static/{*path}` — embedded static files (CSS) |
| 203 | |
| 204 | ### Form deserialization |
| 205 | |
| 206 | ```rust |
| 207 | #[derive(Deserialize)] |
| 208 | struct CreateItemForm { |
| 209 | name: String, |
| 210 | content: String, |
| 211 | } |
| 212 | ``` |
| 213 | |
| 214 | Use `Form(form): Form<CreateItemForm>` for HTML forms, `Json(body): Json<CreateItem>` for API. |
| 215 | |
| 216 | ### Error responses |
| 217 | |
| 218 | - Web handlers return `Result<..., (StatusCode, Html<String>)>` |
| 219 | - API handlers return `Result<..., (StatusCode, Json<serde_json::Value>)>` |
| 220 | - Use `serde_json::json!({"error": "message"})` for API error bodies |
| 221 | |
| 222 | ## Authentication |
| 223 | |
| 224 | ### API key auth middleware |
| 225 | |
| 226 | Configurable per-endpoint authentication using an API key in the `x-api-key` header. Uses constant-time comparison via the `subtle` crate. |
| 227 | |
| 228 | ```rust |
| 229 | #[derive(Clone)] |
| 230 | struct ServerConfig { |
| 231 | api_key: Option<String>, |
| 232 | auth_endpoints: HashSet<String>, |
| 233 | max_content_size: usize, |
| 234 | } |
| 235 | |
| 236 | impl ServerConfig { |
| 237 | fn from_env() -> Self { |
| 238 | let api_key = std::env::var("APP_API_KEY").ok(); |
| 239 | let auth_endpoints = match std::env::var("APP_AUTH_ENDPOINTS") { |
| 240 | Ok(val) if val.trim().eq_ignore_ascii_case("none") => HashSet::new(), |
| 241 | Ok(val) => val.split(',').map(|s| s.trim().to_lowercase()).collect(), |
| 242 | Err(_) => ["api_delete", "api_list", "api_update"] |
| 243 | .iter().map(|s| s.to_string()).collect(), |
| 244 | }; |
| 245 | let max_content_size = std::env::var("APP_MAX_CONTENT_SIZE") |
| 246 | .ok() |
| 247 | .and_then(|v| v.parse().ok()) |
| 248 | .unwrap_or(512_000); |
| 249 | ServerConfig { api_key, auth_endpoints, max_content_size } |
| 250 | } |
| 251 | |
| 252 | fn requires_auth(&self, name: &str) -> bool { |
| 253 | self.auth_endpoints.contains("all") || self.auth_endpoints.contains(name) |
| 254 | } |
| 255 | } |
| 256 | ``` |
| 257 | |
| 258 | ### Auth middleware function |
| 259 | |
| 260 | ```rust |
| 261 | async fn require_api_key( |
| 262 | State(state): State<AppState>, |
| 263 | headers: HeaderMap, |
| 264 | request: Request, |
| 265 | next: Next, |
| 266 | ) -> Result<Response, (StatusCode, Json<serde_json::Value>)> { |
| 267 | let server_key = match &state.server_config.api_key { |
| 268 | Some(k) => k, |
| 269 | None => return Err(( |
| 270 | StatusCode::FORBIDDEN, |
| 271 | Json(serde_json::json!({"error": "No API key configured on server"})), |
| 272 | )), |
| 273 | }; |
| 274 | let provided = headers.get("x-api-key").and_then(|v| v.to_str().ok()); |
| 275 | match provided { |
| 276 | Some(k) if k.as_bytes().ct_eq(server_key.as_bytes()).into() => { |
| 277 | Ok(next.run(request).await) |
| 278 | } |
| 279 | _ => Err(( |
| 280 | StatusCode::UNAUTHORIZED, |
| 281 | Json(serde_json::json!({"error": "Invalid or missing API key"})), |
| 282 | )), |
| 283 | } |
| 284 | } |
| 285 | ``` |
| 286 | |
| 287 | ### Dynamic route building with selective auth |
| 288 | |
| 289 | Build routes dynamically based on which endpoints require auth. Authed routes get the middleware layer; open routes don't. |
| 290 | |
| 291 | ```rust |
| 292 | fn build_api_routes(state: &AppState) -> Router<AppState> { |
| 293 | let config = &state.server_config; |
| 294 | let auth_layer = middleware::from_fn_with_state(state.clone(), require_api_key); |
| 295 | |
| 296 | let mut authed = Router::new(); |
| 297 | let mut open = Router::new(); |
| 298 | |
| 299 | // For each endpoint, add to authed or open router based on config |
| 300 | if config.requires_auth("api_list") { |
| 301 | authed = authed.route("/api/items", get(api_list)); |
| 302 | } else { |
| 303 | open = open.route("/api/items", get(api_list)); |
| 304 | } |
| 305 | // ... repeat for each endpoint |
| 306 | |
| 307 | let authed = authed.route_layer(auth_layer); |
| 308 | authed.merge(open) |
| 309 | } |
| 310 | ``` |
| 311 | |
| 312 | ### Session/cookie auth (for web-facing apps) |
| 313 | |
| 314 | When the app needs a login page instead of API key auth (e.g., personal dashboards), use session-based authentication with a custom Axum extractor. Create a separate `auth.rs` module. |
| 315 | |
| 316 | **Sessions table in db.rs:** |
| 317 | |
| 318 | ```sql |
| 319 | CREATE TABLE IF NOT EXISTS sessions ( |
| 320 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 321 | token TEXT NOT NULL UNIQUE, |
| 322 | expires_at TEXT NOT NULL |
| 323 | ); |
| 324 | ``` |
| 325 | |
| 326 | **Session DB functions in db.rs:** |
| 327 | |
| 328 | ```rust |
| 329 | pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> { |
| 330 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
| 331 | conn.execute("INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)", params![token, expires_at])?; |
| 332 | Ok(()) |
| 333 | } |
| 334 | |
| 335 | pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> { |
| 336 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
| 337 | match conn.query_row("SELECT expires_at FROM sessions WHERE token = ?1", params![token], |row| row.get(0)) { |
| 338 | Ok(val) => Ok(Some(val)), |
| 339 | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
| 340 | Err(e) => Err(DbError::Sqlite(e)), |
| 341 | } |
| 342 | } |
| 343 | |
| 344 | pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> { |
| 345 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
| 346 | conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?; |
| 347 | Ok(()) |
| 348 | } |
| 349 | |
| 350 | pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> { |
| 351 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
| 352 | conn.execute("DELETE FROM sessions WHERE expires_at < datetime('now')", [])?; |
| 353 | Ok(()) |
| 354 | } |
| 355 | ``` |
| 356 | |
| 357 | **auth.rs module:** |
| 358 | |
| 359 | ```rust |
| 360 | use axum::{ |
| 361 | extract::{FromRef, FromRequestParts}, |
| 362 | http::request::Parts, |
| 363 | response::{IntoResponse, Redirect, Response}, |
| 364 | }; |
| 365 | use rand::RngCore; |
| 366 | use subtle::ConstantTimeEq; |
| 367 | |
| 368 | use crate::AppState; |
| 369 | |
| 370 | /// Constant-time password comparison with fixed-length buffers. |
| 371 | pub fn verify_password(input: &str, expected: &str) -> bool { |
| 372 | const LEN: usize = 256; |
| 373 | let mut a = [0u8; LEN]; |
| 374 | let mut b = [0u8; LEN]; |
| 375 | let ib = input.as_bytes(); |
| 376 | let eb = expected.as_bytes(); |
| 377 | a[..ib.len().min(LEN)].copy_from_slice(&ib[..ib.len().min(LEN)]); |
| 378 | b[..eb.len().min(LEN)].copy_from_slice(&eb[..eb.len().min(LEN)]); |
| 379 | let lengths_match = subtle::Choice::from((ib.len() == eb.len()) as u8); |
| 380 | (lengths_match & a.ct_eq(&b)).into() |
| 381 | } |
| 382 | |
| 383 | /// Generate a 32-byte cryptographically random hex token. |
| 384 | pub fn generate_session_token() -> String { |
| 385 | let mut bytes = [0u8; 32]; |
| 386 | rand::rngs::OsRng.fill_bytes(&mut bytes); |
| 387 | bytes.iter().map(|b| format!("{:02x}", b)).collect() |
| 388 | } |
| 389 | |
| 390 | /// Build a session cookie string. |
| 391 | pub fn build_session_cookie(token: &str, secure: bool) -> String { |
| 392 | let mut cookie = format!("session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800", token); |
| 393 | if secure { cookie.push_str("; Secure"); } |
| 394 | cookie |
| 395 | } |
| 396 | |
| 397 | pub fn clear_session_cookie() -> String { |
| 398 | "session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0".to_string() |
| 399 | } |
| 400 | |
| 401 | /// Axum extractor — guards routes behind login. Redirects to /login if invalid. |
| 402 | pub struct AuthSession; |
| 403 | |
| 404 | impl<S> FromRequestParts<S> for AuthSession |
| 405 | where |
| 406 | S: Send + Sync, |
| 407 | Arc<AppState>: FromRef<S>, |
| 408 | { |
| 409 | type Rejection = Response; |
| 410 | |
| 411 | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { |
| 412 | let state = Arc::<AppState>::from_ref(state); |
| 413 | let token = extract_session_cookie(&parts.headers); |
| 414 | if let Some(token) = token { |
| 415 | if is_valid_session(&state, &token) { |
| 416 | return Ok(AuthSession); |
| 417 | } |
| 418 | } |
| 419 | Err(Redirect::to("/login").into_response()) |
| 420 | } |
| 421 | } |
| 422 | |
| 423 | fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> { |
| 424 | let cookie_header = headers.get("cookie")?.to_str().ok()?; |
| 425 | for part in cookie_header.split(';') { |
| 426 | let part = part.trim(); |
| 427 | if let Some(val) = part.strip_prefix("session=") { |
| 428 | let val = val.trim().to_string(); |
| 429 | if !val.is_empty() { return Some(val); } |
| 430 | } |
| 431 | } |
| 432 | None |
| 433 | } |
| 434 | ``` |
| 435 | |
| 436 | **Usage in handlers** — just add `_session: auth::AuthSession` as a parameter to protect a route: |
| 437 | |
| 438 | ```rust |
| 439 | async fn get_index(_session: auth::AuthSession, State(state): State<Arc<AppState>>) -> Response { |
| 440 | // only reachable if session is valid |
| 441 | } |
| 442 | ``` |
| 443 | |
| 444 | **Login/logout routes:** |
| 445 | |
| 446 | ```rust |
| 447 | // GET /login — render login form |
| 448 | // POST /login — verify password, create session, set cookie, redirect to / |
| 449 | // GET /logout — delete session from DB, clear cookie, redirect to /login |
| 450 | ``` |
| 451 | |
| 452 | **AppState for session auth:** |
| 453 | |
| 454 | ```rust |
| 455 | pub struct AppState { |
| 456 | pub db: Db, |
| 457 | pub app_password: String, |
| 458 | pub cookie_secure: bool, |
| 459 | } |
| 460 | ``` |
| 461 | |
| 462 | ### Environment variables |
| 463 | |
| 464 | Prefix all env vars with the app name (e.g., `MYAPP_`): |
| 465 | |
| 466 | | Variable | Purpose | Default | |
| 467 | |----------|---------|---------| |
| 468 | | `APP_API_KEY` | API key for auth | None (auth disabled) | |
| 469 | | `APP_AUTH_ENDPOINTS` | Comma-separated endpoint names, "all", or "none" | `api_delete,api_list,api_update` | |
| 470 | | `APP_MAX_CONTENT_SIZE` | Max request body size in bytes | `512000` | |
| 471 | | `APP_DB_PATH` | SQLite file path | `app.sqlite` | |
| 472 | | `APP_PASSWORD` | Single password for session auth (web apps) | None | |
| 473 | | `COOKIE_SECURE` | Set `true` for HTTPS-only cookies | `false` | |
| 474 | |
| 475 | ## Templates (Askama) |
| 476 | |
| 477 | HTML templates live in `templates/` and use Askama syntax. Key patterns: |
| 478 | |
| 479 | - Link CSS via `/static/styles.css` |
| 480 | - Link assets via `/assets/filename` |
| 481 | - Include `<meta name="theme-color" content="#121113" />` |
| 482 | - Forms POST to web routes (not API routes) |
| 483 | - Use `{{ variable }}` for template interpolation |
| 484 | - Use `{{ variable|safe }}` for pre-rendered HTML (e.g., syntax highlighted content) |
| 485 | |
| 486 | ### Template inheritance |
| 487 | |
| 488 | Use a `base.html` with block sections. All pages extend it: |
| 489 | |
| 490 | **templates/base.html:** |
| 491 | ```html |
| 492 | <!DOCTYPE html> |
| 493 | <html lang="en"> |
| 494 | <head> |
| 495 | <meta charset="UTF-8"> |
| 496 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 497 | <title>{% block title %}APP_NAME{% endblock %}</title> |
| 498 | <meta name="theme-color" content="#121113" /> |
| 499 | <style> |
| 500 | /* base styles here */ |
| 501 | </style> |
| 502 | </head> |
| 503 | <body> |
| 504 | <div class="container"> |
| 505 | {% block content %}{% endblock %} |
| 506 | </div> |
| 507 | </body> |
| 508 | </html> |
| 509 | ``` |
| 510 | |
| 511 | **templates/index.html:** |
| 512 | ```html |
| 513 | {% extends "base.html" %} |
| 514 | {% block title %}Items{% endblock %} |
| 515 | {% block content %} |
| 516 | {% if let Some(error) = error %} |
| 517 | <p class="error">{{ error }}</p> |
| 518 | {% endif %} |
| 519 | {% for item in items %} |
| 520 | <div>{{ item.name }}</div> |
| 521 | {% endfor %} |
| 522 | {% endblock %} |
| 523 | ``` |
| 524 | |
| 525 | ### Flash messages via query params |
| 526 | |
| 527 | Pass transient error/success messages through redirects using query parameters. No session flash needed. |
| 528 | |
| 529 | **Query param struct:** |
| 530 | ```rust |
| 531 | #[derive(Deserialize, Default)] |
| 532 | pub struct FlashQuery { |
| 533 | pub error: Option<String>, |
| 534 | } |
| 535 | ``` |
| 536 | |
| 537 | **In handlers** — redirect with message: |
| 538 | ```rust |
| 539 | Redirect::to("/items/add?error=Name+is+required.").into_response() |
| 540 | ``` |
| 541 | |
| 542 | **In receiving handler** — extract and pass to template: |
| 543 | ```rust |
| 544 | async fn get_add(Query(q): Query<FlashQuery>) -> Response { |
| 545 | render(AddTemplate { error: q.error }) |
| 546 | } |
| 547 | ``` |
| 548 | |
| 549 | **In template** — conditionally render: |
| 550 | ```html |
| 551 | {% if let Some(error) = error %} |
| 552 | <p class="error">{{ error }}</p> |
| 553 | {% endif %} |
| 554 | ``` |
| 555 | |
| 556 | ## Logging (tracing) |
| 557 | |
| 558 | Always initialize tracing in `main()` before anything else: |
| 559 | |
| 560 | ```rust |
| 561 | tracing_subscriber::fmt::init(); |
| 562 | ``` |
| 563 | |
| 564 | Use throughout the app: |
| 565 | - `tracing::error!("DB error: {}", e)` — unrecoverable failures |
| 566 | - `tracing::warn!("Non-critical issue: {}", e)` — degraded but functional |
| 567 | - `tracing::info!("Listening on {}", addr)` — startup/lifecycle events |
| 568 | |
| 569 | ## main.rs |
| 570 | |
| 571 | Minimal — just starts the server: |
| 572 | |
| 573 | ```rust |
| 574 | mod db; |
| 575 | mod server; |
| 576 | |
| 577 | #[tokio::main] |
| 578 | async fn main() { |
| 579 | tracing_subscriber::fmt::init(); |
| 580 | let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); |
| 581 | let port: u16 = std::env::var("PORT") |
| 582 | .ok() |
| 583 | .and_then(|v| v.parse().ok()) |
| 584 | .unwrap_or(3000); |
| 585 | server::run(host, port).await; |
| 586 | } |
| 587 | ``` |
| 588 | |
| 589 | If the app needs CLI arguments (e.g., `--port`, `--host`), add `clap` and a simple arg struct. But default to env vars and keep main.rs minimal. |
| 590 | |
| 591 | ## Dockerfile |
| 592 | |
| 593 | Multi-stage build: |
| 594 | |
| 595 | ```dockerfile |
| 596 | FROM rust:1-slim-bookworm AS builder |
| 597 | WORKDIR /app |
| 598 | COPY . . |
| 599 | RUN cargo build --release |
| 600 | |
| 601 | FROM debian:bookworm-slim |
| 602 | COPY --from=builder /app/target/release/APP_NAME /usr/local/bin/APP_NAME |
| 603 | WORKDIR /data |
| 604 | EXPOSE 3000 |
| 605 | CMD ["APP_NAME", "--port", "3000", "--host", "0.0.0.0"] |
| 606 | ``` |
| 607 | |
| 608 | Replace `APP_NAME` with the actual binary name. |
| 609 | |
| 610 | ## docker-compose.yml |
| 611 | |
| 612 | ```yaml |
| 613 | services: |
| 614 | app: |
| 615 | build: . |
| 616 | ports: |
| 617 | - "3000:3000" |
| 618 | environment: |
| 619 | - APP_API_KEY=${APP_API_KEY:-changeme} |
| 620 | - APP_AUTH_ENDPOINTS=api_delete,api_list |
| 621 | volumes: |
| 622 | - app-data:/data |
| 623 | restart: unless-stopped |
| 624 | |
| 625 | volumes: |
| 626 | app-data: |
| 627 | ``` |
| 628 | |
| 629 | Key: use a named volume to persist the SQLite database across container restarts. |
| 630 | |
| 631 | ## .env.example |
| 632 | |
| 633 | Always create one with all configurable env vars and sensible comments. |
| 634 | |
| 635 | ## Checklist |
| 636 | |
| 637 | When scaffolding a new app with this pattern: |
| 638 | |
| 639 | 1. Create project structure (`cargo init`, add directories) |
| 640 | 2. Set up `Cargo.toml` with dependencies |
| 641 | 3. Write `db.rs` — schema, model struct, CRUD functions |
| 642 | 4. Write `server.rs` — config, state, templates, handlers, auth, routes |
| 643 | 5. Write `main.rs` — minimal entry point |
| 644 | 6. Create `templates/` with at least an index page |
| 645 | 7. Create `static/styles.css` |
| 646 | 8. Create `.env.example` |
| 647 | 9. Create `Dockerfile` and `docker-compose.yml` |
| 648 | 10. Test: `cargo run`, verify routes work |
| 649 | |
| 650 | ## What NOT to include |
| 651 | |
| 652 | - No external CSS frameworks unless specified |
| 653 | - No ORMs — use raw rusqlite |
| 654 | - No connection pools — `Arc<Mutex<Connection>>` is sufficient for SQLite |
| 655 | - No async database drivers — rusqlite is synchronous and that's fine |