| name | server-rust |
| description | Patterns and anti-patterns for the Rust/Axum server defined in spec 0005. Use when implementing or reviewing any file in servers/rust/. Trigger phrases: "rust server", "axum", "sqlx", "/server-rust".
|
| applyTo | ["servers/rust/**"] |
Server: Rust + Axum 0.8 + sqlx (spec 0005)
Build command (from Makefile)
cd servers/rust && cargo build --release \
--target aarch64-unknown-linux-gnu
Cross-compilation from macOS or x86-64 Linux requires either:
cargo cross (recommended): cross build --release --target aarch64-unknown-linux-gnu
- Manual linker setup: add
[target.aarch64-unknown-linux-gnu] linker = "aarch64-linux-gnu-gcc" to ~/.cargo/config.toml
Without the correct linker, cargo build silently compiles for the host target,
not arm64. Always verify the output file: file target/aarch64-unknown-linux-gnu/release/server.
Cargo.toml — exact versions (do not bump without updating the spec)
[dependencies]
axum = "0.8.9"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Cargo.lock must be committed.
Module layout (from spec 0005)
servers/rust/
├── Cargo.toml
├── Cargo.lock # committed
└── src/
├── main.rs # entry point: reads env, opens DB pool, starts server
├── handlers.rs # Axum handler functions (one per endpoint)
└── db.rs # sqlx pool setup and query helpers
Axum 0.8 routing
let app = Router::new()
.route("/items", get(handlers::list_items).post(handlers::create_item))
.route("/items/:id", get(handlers::get_item).delete(handlers::delete_item))
.with_state(pool);
Path parameters use :id syntax in 0.8. {id} syntax is axum-extra typed routing.
Handler return types
All handlers must return a type that implements IntoResponse. The idiomatic
approach is to use impl IntoResponse or a concrete response type:
async fn create_item(
State(pool): State<SqlitePool>,
Json(body): Json<CreateItemRequest>,
) -> impl IntoResponse {
(StatusCode::CREATED, Json(item))
}
async fn delete_item(
State(pool): State<SqlitePool>,
Path(id): Path<i64>,
) -> impl IntoResponse {
match db::delete_item(&pool, id).await {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => (StatusCode::NOT_FOUND, Json(json!({"error":"not found"}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(),
}
}
sqlx — SQLite async patterns
Pool setup
let db_path = std::env::var("DB_PATH")
.unwrap_or_else(|_| "../../data/benchmark.db".to_string());
let pool = SqlitePool::connect(&format!("sqlite:{db_path}"))
.await
.expect("Failed to connect to SQLite");
Queries
pub async fn list_items(pool: &SqlitePool) -> Result<Vec<Item>, sqlx::Error> {
sqlx::query_as!(Item, "SELECT id, name, created_at FROM items ORDER BY id")
.fetch_all(pool)
.await
}
pub async fn get_item(pool: &SqlitePool, id: i64) -> Result<Option<Item>, sqlx::Error> {
sqlx::query_as!(Item, "SELECT id, name, created_at FROM items WHERE id = ?", id)
.fetch_optional(pool)
.await
}
fetch_optional returns Ok(None) when no row matches — map this to 404, not 500.
sqlx::Error::RowNotFound only comes from fetch_one. Prefer fetch_optional to
avoid the RowNotFound error path.
sqlx offline compilation
sqlx::query! macros verify queries against a live DB at compile time.
For CI or environments without a seeded DB, use offline mode:
DATABASE_URL="sqlite:../../data/benchmark.db" cargo sqlx prepare
SQLX_OFFLINE=true cargo build --release
Commit the .sqlx/ directory so CI can build offline.
created_at — UTC with Z suffix
use chrono::Utc;
let created_at = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let created_at = Utc::now().to_rfc3339();
Or use the time crate (already pulled in by sqlx):
use time::OffsetDateTime;
let created_at = OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap()
.replace("+00:00", "Z");
Single-process constraint (RF6)
Tokio multi-thread runtime is acceptable — it does not fork processes.
The constraint is about OS-level processes (no std::process::Command to spawn
worker processes, no Unix fork).
Anti-patterns
| Anti-pattern | Problem | Fix |
|---|
sqlx::query!("...").fetch_one() on a lookup | Panics with RowNotFound instead of 404 | Use fetch_optional |
unwrap() on DB results in handlers | Crashes the server on transient errors | Use ? or match |
Blocking I/O inside async fn (e.g., std::fs::read) | Blocks the Tokio executor, degrades throughput | Use tokio::fs or spawn_blocking |
cargo build without --target | Builds for host, not arm64 | Always use --target aarch64-unknown-linux-gnu |
Utc::now().to_rfc3339() | Produces +00:00 suffix, not Z | Use format string or .replace |