rust-crud/SKILL.md 18.8 K raw
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