---
name: rust-crud
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.
---

# Rust CRUD Web App

## Overview

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.

## Project Structure

```
project-name/
├── src/
│   ├── main.rs         # Entry point, starts the server
│   ├── server.rs       # Axum routes, middleware, handlers, static asset serving
│   ├── db.rs           # SQLite schema, CRUD functions, error types
│   └── auth.rs         # Session/cookie auth (when using login-based auth)
├── templates/          # Askama HTML templates
│   ├── base.html       # Base layout with blocks (title, content)
│   └── *.html          # Pages extend base.html
├── static/             # CSS, fonts, favicons (embedded via rust_embed or served via tower-http)
│   └── styles.css
├── assets/             # Favicons, fonts, images (embedded via rust_embed)
├── .env.example        # Environment variable reference
├── Dockerfile          # Multi-stage build
└── docker-compose.yml  # Compose config with volume for DB persistence
```

## Dependencies (Cargo.toml)

Use these exact crates and features:

```toml
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
askama = "0.15"
askama_web = { version = "0.15", features = ["axum-0.8"] }
rusqlite = { version = "0.38", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
nanoid = "0.4.0"
rust-embed = "8"
dotenvy = "0.15"
subtle = "2"
tracing = "0.1"
tracing-subscriber = "0.3"
rand = "0.8"
tower-http = { version = "0.6", features = ["fs"] }
```

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.

- `tracing` + `tracing-subscriber` — structured logging, always include
- `rand` — needed for session token generation when using session-based auth
- `tower-http` — alternative to `rust-embed` for serving static files from disk (simpler during development, no recompile on asset changes)

## Database Layer (db.rs)

Pattern: single-file module with `Arc<Mutex<Connection>>` for thread-safe SQLite access.

### Structure

```rust
use nanoid::nanoid;
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::sync::{Arc, Mutex};

pub type Db = Arc<Mutex<Connection>>;

#[derive(Debug)]
pub enum DbError {
    Sqlite(rusqlite::Error),
    LockPoisoned,
}

impl fmt::Display for DbError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            DbError::Sqlite(e) => write!(f, "Database error: {}", e),
            DbError::LockPoisoned => write!(f, "Database lock poisoned"),
        }
    }
}

impl std::error::Error for DbError {}

impl From<rusqlite::Error> for DbError {
    fn from(e: rusqlite::Error) -> Self {
        DbError::Sqlite(e)
    }
}
```

### Key patterns

- **Model struct**: derive `Serialize, Deserialize`, all fields `pub`
- **ID generation**: `nanoid!(10)` for short unique IDs
- **DB path from env**: `std::env::var("APP_DB_PATH").unwrap_or_else(|_| "app.sqlite".to_string())`
- **init_db()**: opens connection, runs `CREATE TABLE IF NOT EXISTS`, returns `Arc<Mutex<Connection>>`
- **CRUD functions**: standalone functions that take `&Db` as first param
  - `create_*` — INSERT, return created model with `last_insert_rowid()`
  - `get_*_by_short_id` — SELECT, return `Result<Option<Model>, DbError>`
  - `get_all_*` — SELECT with ORDER BY id DESC
  - `delete_*_by_short_id` — DELETE, return `Result<bool, DbError>` (rows_affected > 0)
  - `update_*_by_short_id` — UPDATE then SELECT to return updated model
- **Error handling**: `QueryReturnedNoRows` maps to `Ok(None)`, not an error

## Server Layer (server.rs)

### Embedded assets with rust_embed

```rust
use rust_embed::Embed;

#[derive(Embed)]
#[folder = "assets/"]
struct Assets;

#[derive(Embed)]
#[folder = "static/"]
struct Static;
```

Serve with handlers that match on file path and return correct MIME types:

```rust
fn mime_from_path(path: &str) -> &'static str {
    match path.rsplit('.').next().unwrap_or("") {
        "css" => "text/css",
        "js" => "application/javascript",
        "html" => "text/html",
        "png" => "image/png",
        "ico" => "image/x-icon",
        "svg" => "image/svg+xml",
        "woff" | "woff2" => "font/woff2",
        "ttf" => "font/ttf",
        "otf" => "font/otf",
        "json" | "webmanifest" => "application/json",
        _ => "application/octet-stream",
    }
}
```

### App state

```rust
#[derive(Clone)]
struct AppState {
    db: Db,
    server_config: ServerConfig,
}
```

Add domain-specific fields as needed (e.g., a highlighter, cache, etc).

### Askama templates

```rust
use askama::Template;
use askama_web::WebTemplate;

#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate;

// Templates with data:
#[derive(Template)]
#[template(path = "item.html")]
struct ItemTemplate {
    name: String,
    content: String,
}
```

Render with `WebTemplate(MyTemplate { ... })`.

### Route structure

Two sets of routes: **web routes** (HTML pages + form submissions) and **API routes** (JSON).

**Web routes:**
- `GET /` — index page (template)
- `GET /admin` — admin panel (template)
- `POST /items` — form submission, redirects on success
- `GET /items/{short_id}` — view single item (template)

**API routes:**
- `GET /api/items` — list all (JSON)
- `POST /api/items` — create (JSON body → 201 + JSON)
- `GET /api/items/{short_id}` — get one (JSON)
- `PUT /api/items/{short_id}` — update (JSON body → JSON)
- `DELETE /api/items/{short_id}` — delete (JSON)

**Static asset routes:**
- `GET /assets/{*path}` — embedded assets (favicons, fonts, images)
- `GET /static/{*path}` — embedded static files (CSS)

### Form deserialization

```rust
#[derive(Deserialize)]
struct CreateItemForm {
    name: String,
    content: String,
}
```

Use `Form(form): Form<CreateItemForm>` for HTML forms, `Json(body): Json<CreateItem>` for API.

### Error responses

- Web handlers return `Result<..., (StatusCode, Html<String>)>`
- API handlers return `Result<..., (StatusCode, Json<serde_json::Value>)>`
- Use `serde_json::json!({"error": "message"})` for API error bodies

## Authentication

### API key auth middleware

Configurable per-endpoint authentication using an API key in the `x-api-key` header. Uses constant-time comparison via the `subtle` crate.

```rust
#[derive(Clone)]
struct ServerConfig {
    api_key: Option<String>,
    auth_endpoints: HashSet<String>,
    max_content_size: usize,
}

impl ServerConfig {
    fn from_env() -> Self {
        let api_key = std::env::var("APP_API_KEY").ok();
        let auth_endpoints = match std::env::var("APP_AUTH_ENDPOINTS") {
            Ok(val) if val.trim().eq_ignore_ascii_case("none") => HashSet::new(),
            Ok(val) => val.split(',').map(|s| s.trim().to_lowercase()).collect(),
            Err(_) => ["api_delete", "api_list", "api_update"]
                .iter().map(|s| s.to_string()).collect(),
        };
        let max_content_size = std::env::var("APP_MAX_CONTENT_SIZE")
            .ok()
            .and_then(|v| v.parse().ok())
            .unwrap_or(512_000);
        ServerConfig { api_key, auth_endpoints, max_content_size }
    }

    fn requires_auth(&self, name: &str) -> bool {
        self.auth_endpoints.contains("all") || self.auth_endpoints.contains(name)
    }
}
```

### Auth middleware function

```rust
async fn require_api_key(
    State(state): State<AppState>,
    headers: HeaderMap,
    request: Request,
    next: Next,
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
    let server_key = match &state.server_config.api_key {
        Some(k) => k,
        None => return Err((
            StatusCode::FORBIDDEN,
            Json(serde_json::json!({"error": "No API key configured on server"})),
        )),
    };
    let provided = headers.get("x-api-key").and_then(|v| v.to_str().ok());
    match provided {
        Some(k) if k.as_bytes().ct_eq(server_key.as_bytes()).into() => {
            Ok(next.run(request).await)
        }
        _ => Err((
            StatusCode::UNAUTHORIZED,
            Json(serde_json::json!({"error": "Invalid or missing API key"})),
        )),
    }
}
```

### Dynamic route building with selective auth

Build routes dynamically based on which endpoints require auth. Authed routes get the middleware layer; open routes don't.

```rust
fn build_api_routes(state: &AppState) -> Router<AppState> {
    let config = &state.server_config;
    let auth_layer = middleware::from_fn_with_state(state.clone(), require_api_key);

    let mut authed = Router::new();
    let mut open = Router::new();

    // For each endpoint, add to authed or open router based on config
    if config.requires_auth("api_list") {
        authed = authed.route("/api/items", get(api_list));
    } else {
        open = open.route("/api/items", get(api_list));
    }
    // ... repeat for each endpoint

    let authed = authed.route_layer(auth_layer);
    authed.merge(open)
}
```

### Session/cookie auth (for web-facing apps)

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.

**Sessions table in db.rs:**

```sql
CREATE TABLE IF NOT EXISTS sessions (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    token      TEXT NOT NULL UNIQUE,
    expires_at TEXT NOT NULL
);
```

**Session DB functions in db.rs:**

```rust
pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> {
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
    conn.execute("INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)", params![token, expires_at])?;
    Ok(())
}

pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> {
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
    match conn.query_row("SELECT expires_at FROM sessions WHERE token = ?1", params![token], |row| row.get(0)) {
        Ok(val) => Ok(Some(val)),
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
        Err(e) => Err(DbError::Sqlite(e)),
    }
}

pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> {
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
    conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?;
    Ok(())
}

pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> {
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
    conn.execute("DELETE FROM sessions WHERE expires_at < datetime('now')", [])?;
    Ok(())
}
```

**auth.rs module:**

```rust
use axum::{
    extract::{FromRef, FromRequestParts},
    http::request::Parts,
    response::{IntoResponse, Redirect, Response},
};
use rand::RngCore;
use subtle::ConstantTimeEq;

use crate::AppState;

/// Constant-time password comparison with fixed-length buffers.
pub fn verify_password(input: &str, expected: &str) -> bool {
    const LEN: usize = 256;
    let mut a = [0u8; LEN];
    let mut b = [0u8; LEN];
    let ib = input.as_bytes();
    let eb = expected.as_bytes();
    a[..ib.len().min(LEN)].copy_from_slice(&ib[..ib.len().min(LEN)]);
    b[..eb.len().min(LEN)].copy_from_slice(&eb[..eb.len().min(LEN)]);
    let lengths_match = subtle::Choice::from((ib.len() == eb.len()) as u8);
    (lengths_match & a.ct_eq(&b)).into()
}

/// Generate a 32-byte cryptographically random hex token.
pub fn generate_session_token() -> String {
    let mut bytes = [0u8; 32];
    rand::rngs::OsRng.fill_bytes(&mut bytes);
    bytes.iter().map(|b| format!("{:02x}", b)).collect()
}

/// Build a session cookie string.
pub fn build_session_cookie(token: &str, secure: bool) -> String {
    let mut cookie = format!("session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800", token);
    if secure { cookie.push_str("; Secure"); }
    cookie
}

pub fn clear_session_cookie() -> String {
    "session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0".to_string()
}

/// Axum extractor — guards routes behind login. Redirects to /login if invalid.
pub struct AuthSession;

impl<S> FromRequestParts<S> for AuthSession
where
    S: Send + Sync,
    Arc<AppState>: FromRef<S>,
{
    type Rejection = Response;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        let state = Arc::<AppState>::from_ref(state);
        let token = extract_session_cookie(&parts.headers);
        if let Some(token) = token {
            if is_valid_session(&state, &token) {
                return Ok(AuthSession);
            }
        }
        Err(Redirect::to("/login").into_response())
    }
}

fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
    let cookie_header = headers.get("cookie")?.to_str().ok()?;
    for part in cookie_header.split(';') {
        let part = part.trim();
        if let Some(val) = part.strip_prefix("session=") {
            let val = val.trim().to_string();
            if !val.is_empty() { return Some(val); }
        }
    }
    None
}
```

**Usage in handlers** — just add `_session: auth::AuthSession` as a parameter to protect a route:

```rust
async fn get_index(_session: auth::AuthSession, State(state): State<Arc<AppState>>) -> Response {
    // only reachable if session is valid
}
```

**Login/logout routes:**

```rust
// GET /login — render login form
// POST /login — verify password, create session, set cookie, redirect to /
// GET /logout — delete session from DB, clear cookie, redirect to /login
```

**AppState for session auth:**

```rust
pub struct AppState {
    pub db: Db,
    pub app_password: String,
    pub cookie_secure: bool,
}
```

### Environment variables

Prefix all env vars with the app name (e.g., `MYAPP_`):

| Variable | Purpose | Default |
|----------|---------|---------|
| `APP_API_KEY` | API key for auth | None (auth disabled) |
| `APP_AUTH_ENDPOINTS` | Comma-separated endpoint names, "all", or "none" | `api_delete,api_list,api_update` |
| `APP_MAX_CONTENT_SIZE` | Max request body size in bytes | `512000` |
| `APP_DB_PATH` | SQLite file path | `app.sqlite` |
| `APP_PASSWORD` | Single password for session auth (web apps) | None |
| `COOKIE_SECURE` | Set `true` for HTTPS-only cookies | `false` |

## Templates (Askama)

HTML templates live in `templates/` and use Askama syntax. Key patterns:

- Link CSS via `/static/styles.css`
- Link assets via `/assets/filename`
- Include `<meta name="theme-color" content="#121113" />`
- Forms POST to web routes (not API routes)
- Use `{{ variable }}` for template interpolation
- Use `{{ variable|safe }}` for pre-rendered HTML (e.g., syntax highlighted content)

### Template inheritance

Use a `base.html` with block sections. All pages extend it:

**templates/base.html:**
```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{% block title %}APP_NAME{% endblock %}</title>
  <meta name="theme-color" content="#121113" />
  <style>
    /* base styles here */
  </style>
</head>
<body>
  <div class="container">
    {% block content %}{% endblock %}
  </div>
</body>
</html>
```

**templates/index.html:**
```html
{% extends "base.html" %}
{% block title %}Items{% endblock %}
{% block content %}
  {% if let Some(error) = error %}
    <p class="error">{{ error }}</p>
  {% endif %}
  {% for item in items %}
    <div>{{ item.name }}</div>
  {% endfor %}
{% endblock %}
```

### Flash messages via query params

Pass transient error/success messages through redirects using query parameters. No session flash needed.

**Query param struct:**
```rust
#[derive(Deserialize, Default)]
pub struct FlashQuery {
    pub error: Option<String>,
}
```

**In handlers** — redirect with message:
```rust
Redirect::to("/items/add?error=Name+is+required.").into_response()
```

**In receiving handler** — extract and pass to template:
```rust
async fn get_add(Query(q): Query<FlashQuery>) -> Response {
    render(AddTemplate { error: q.error })
}
```

**In template** — conditionally render:
```html
{% if let Some(error) = error %}
  <p class="error">{{ error }}</p>
{% endif %}
```

## Logging (tracing)

Always initialize tracing in `main()` before anything else:

```rust
tracing_subscriber::fmt::init();
```

Use throughout the app:
- `tracing::error!("DB error: {}", e)` — unrecoverable failures
- `tracing::warn!("Non-critical issue: {}", e)` — degraded but functional
- `tracing::info!("Listening on {}", addr)` — startup/lifecycle events

## main.rs

Minimal — just starts the server:

```rust
mod db;
mod server;

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();
    let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
    let port: u16 = std::env::var("PORT")
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(3000);
    server::run(host, port).await;
}
```

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.

## Dockerfile

Multi-stage build:

```dockerfile
FROM rust:1-slim-bookworm AS builder
WORKDIR /app
COPY . .
RUN cargo build --release

FROM debian:bookworm-slim
COPY --from=builder /app/target/release/APP_NAME /usr/local/bin/APP_NAME
WORKDIR /data
EXPOSE 3000
CMD ["APP_NAME", "--port", "3000", "--host", "0.0.0.0"]
```

Replace `APP_NAME` with the actual binary name.

## docker-compose.yml

```yaml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - APP_API_KEY=${APP_API_KEY:-changeme}
      - APP_AUTH_ENDPOINTS=api_delete,api_list
    volumes:
      - app-data:/data
    restart: unless-stopped

volumes:
  app-data:
```

Key: use a named volume to persist the SQLite database across container restarts.

## .env.example

Always create one with all configurable env vars and sensible comments.

## Checklist

When scaffolding a new app with this pattern:

1. Create project structure (`cargo init`, add directories)
2. Set up `Cargo.toml` with dependencies
3. Write `db.rs` — schema, model struct, CRUD functions
4. Write `server.rs` — config, state, templates, handlers, auth, routes
5. Write `main.rs` — minimal entry point
6. Create `templates/` with at least an index page
7. Create `static/styles.css`
8. Create `.env.example`
9. Create `Dockerfile` and `docker-compose.yml`
10. Test: `cargo run`, verify routes work

## What NOT to include

- No external CSS frameworks unless specified 
- No ORMs — use raw rusqlite
- No connection pools — `Arc<Mutex<Connection>>` is sufficient for SQLite
- No async database drivers — rusqlite is synchronous and that's fine
