Complete these tasks to reinforce what you learned in this module.
Turn the crate into a multi-file binary. In src/main.rs declare
the sibling modules (mod config; mod error; mod state; mod auth; mod cache; mod
worker; mod routes;) and create the corresponding files. Make routes.rs a
*directory module* that declares pub mod items; with src/routes/items.rs. Add
the dependencies to Cargo.toml: axum 0.8, tokio 1 (full), sqlx 0.8
(runtime-tokio, postgres), redis 1 (tokio-comp), serde/serde_json,
tower-http 0.7 (trace), tracing + tracing-subscriber, thiserror, anyhow,
dotenvy.
mod foo; *loads* foo.rs — it is not an import (module 37).
The directory form puts the parent code in routes.rs and the child in
routes/items.rs.In config.rs, define a Config struct with database_url,
redis_url, bind_addr, api_token, cache_ttl_secs, and
worker_interval_secs. Add Config::from_env() that calls dotenvy::dotenv(),
reads each value from the environment, and falls back to a sensible default when
unset (e.g. BIND_ADDR → 127.0.0.1:3000).
std::env::var("X").unwrap_or_else(|_| "default".into()). For the
numeric tunables, parse with .parse().ok() and fall back on failure. Mirror
module 24.In error.rs, define enum AppError with thiserror. Include at
least NotFound (404), Unauthorized (401), BadRequest(String) (400), and
wrapped variants for sqlx::Error, redis::RedisError, and serde_json::Error
(500) using #[from]. Implement axum::response::IntoResponse for AppError,
mapping each variant to a StatusCode and a JSON body like {"error": "..."}.
Log 5xx detail but do not leak it to clients.
#[from] generates the From impls that make ? work. In
into_response, build (status, Json(json!({"error": msg}))).into_response().
This is the composition lesson — see module 27.In state.rs, define #[derive(Clone)] struct AppState { pool:
PgPool, redis: redis::aio::MultiplexedConnection, api_token: Arc<str>,
cache_ttl_secs: u64 } plus a constructor. In main.rs, build the pool with
PgPoolOptions::new()....connect(&config.database_url) and the Redis connection
with redis::Client::open(...)?.get_multiplexed_async_connection().await?, then
assemble AppState.
AppState field must be cheap to Clone (axum clones it per
request). PgPool and MultiplexedConnection are already Arc-backed; wrap the
token in Arc<str>. Mirror module 28 for state, module 20 for the pool, module 31
for the connection.In routes/items.rs, define an Item struct deriving
Serialize, Deserialize, FromRow, and #[serde(rename_all = "camelCase")]
(fields id: i64, name, description, price_cents: i64, created_at: i64).
Write three handlers using runtime sqlx queries: list_items
(query_as + fetch_all), get_item (Path<i64>, fetch_optional, → 404 on
None), and create_item (Json<NewItem>, INSERT ... RETURNING, → 201). In
routes.rs, build the router: GET /health, GET/POST /items,
GET /items/{id}, and .with_state(state). Apply migrations/001_init.sql to
create the table.
"/items/{id}", not :id. One struct can be
both the DB row (FromRow) and the JSON DTO (serde). query_as::<_, Item>(sql)
with .bind(...); fetch_optional(...).await?.ok_or(AppError::NotFound)?.In cache.rs, write generic helpers over
&mut MultiplexedConnection: get_json<T: DeserializeOwned>(conn, key) ->
Result<Option<T>, AppError> and set_json<T: Serialize>(conn, key, value,
ttl_secs), plus an invalidate(conn, key). Wire them into get_item: check the
cache first, on a miss read Postgres and warm the cache with cache_ttl_secs;
invalidate the key in create_item.
redis::AsyncCommands trait. get returns
Option<String> (nil → None); annotate the discarded set_ex result as
let _: () = ... (module 31). Serialize values with serde_json::to_string.In auth.rs, define struct AuthUser and implement
FromRequestParts<AppState> for it (axum 0.8 style — a plain async fn, no
#[async_trait]). Read the Authorization header, require a Bearer prefix,
and compare the token to state.api_token; return Err(AppError::Unauthorized)
otherwise. Add an AuthUser parameter to create_item so POST /items is
protected (reads stay open).
async fn from_request_parts(parts: &mut Parts, state:
&AppState) -> Result<Self, Self::Rejection> with type Rejection = AppError.
Extractor order in the handler matters: State and AuthUser (request *parts*)
come before the body-consuming Json. Mirror module 29.In worker.rs, write run(state, shutdown:
watch::Receiver<bool>, interval_secs) that loops on a tokio::time::interval and
does periodic work (e.g. SELECT COUNT(*) FROM items cached under
stats:item_count), using tokio::select! to also wake on shutdown.changed()
and break. In main.rs: add TraceLayer::new_for_http() to the router,
tokio::spawn the worker, and serve with
axum::serve(listener, app).with_graceful_shutdown(shutdown_signal(tx)). The
shutdown signal future waits for Ctrl-C / SIGTERM, sends true on the watch
channel (stopping the worker), and lets the server drain; then await the worker
handle.
watch channel broadcasts the shutdown flag; the *same* signal future
drives with_graceful_shutdown. tokio::select! races the interval tick against
shutdown.changed() — the BackgroundService + CancellationToken pattern from
module 32. A failed tick should log and continue, not kill the worker.
## Stretch goals (optional)
- Add PUT /items/{id} and DELETE /items/{id}, invalidating the cache on write.
- Build the Docker image (docker build -f 38_capstone/Dockerfile -t capstone .
from the workspace root) and run the whole stack with docker run (module 34).
- Swap the static API_TOKEN check for real JWT validation with jsonwebtoken,
decoding the sub claim into AuthUser (mirrors the Keycloak setup in the C#
backend).