caa12f5366
Bite-sized TDD plan: role-named workspace, clap-derive config, sqlx Db handle, axum health probes + config-driven OpenAPI, server wiring, local dev tooling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1033 lines
27 KiB
Markdown
1033 lines
27 KiB
Markdown
# Foundation Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace the placeholder single-package scaffold with a role-named Cargo workspace running a tested, single-tenant axum server that exposes health probes and a config-driven OpenAPI document.
|
||
|
||
**Architecture:** A Cargo virtual workspace with role-named crates (`domain`, `db`, `api`, `server`). `domain` holds pure types (no I/O); `db` owns all SQL behind a `Db` handle; `api` builds the axum router, handlers, and OpenAPI doc; `server` is the binary that loads config (clap derive, env+args), connects the database, and serves. The product name is never hardcoded — it comes from config and flows into the OpenAPI title.
|
||
|
||
**Tech Stack:** Rust 2024, tokio, axum 0.8, sqlx 0.8 (PostgreSQL), clap 4 (derive), utoipa 5 (code-first OpenAPI), tracing. Tests use `#[sqlx::test]` + `tower::ServiceExt::oneshot` and a TCP smoke test with reqwest.
|
||
|
||
---
|
||
|
||
## Prerequisites (read once before starting)
|
||
|
||
- **Rust** stable ≥ 1.85 (edition 2024) and the **nightly** toolchain for formatting (`cargo +nightly fmt`).
|
||
- **Docker** (for a local PostgreSQL during tests) — or any reachable PostgreSQL where the connecting role may `CREATE DATABASE` (required by `#[sqlx::test]`, which provisions a temp DB per test).
|
||
- **`just`** (optional, for the task runner added in Task 7).
|
||
- **Verify crate versions before pinning.** Versions below are expected-current for 2026; confirm the latest compatible release with the `cratesio-mcp` tools and adjust if needed. Add/update dependencies with `cargo-mcp` rather than hand-editing where practical.
|
||
- Set `DATABASE_URL` before running DB-backed tests, e.g. `export DATABASE_URL=postgres://postgres:postgres@localhost:5432/cms_dev` (Task 7 ships a `docker-compose.yml` and `.env.example`).
|
||
|
||
## File Structure
|
||
|
||
```
|
||
Cargo.toml virtual workspace (no [package])
|
||
crates/
|
||
domain/
|
||
Cargo.toml
|
||
src/lib.rs re-exports
|
||
src/id.rs newtype IDs (OrgId)
|
||
db/
|
||
Cargo.toml
|
||
src/lib.rs Db handle: connect/from_pool/pool/ping
|
||
tests/ping.rs #[sqlx::test] ping
|
||
api/
|
||
Cargo.toml
|
||
src/lib.rs AppState, build_app
|
||
src/health.rs /health/live, /health/ready
|
||
src/openapi.rs ApiDoc + /api-docs/openapi.json
|
||
tests/health.rs oneshot tests
|
||
server/
|
||
Cargo.toml
|
||
src/lib.rs Config re-export, run(), serve()
|
||
src/config.rs clap-derive Config
|
||
src/main.rs binary entrypoint
|
||
tests/config.rs arg/env parsing
|
||
tests/serve.rs TCP smoke test
|
||
docker-compose.yml local Postgres
|
||
.env.example
|
||
justfile
|
||
```
|
||
|
||
Removed: the root `[package]` and `src/main.rs` (the `biggus-dickus` hello-world scaffold).
|
||
|
||
---
|
||
|
||
## Task 1: Workspace skeleton (dissolve the placeholder package)
|
||
|
||
**Files:**
|
||
- Modify: `Cargo.toml` (replace entire contents)
|
||
- Delete: `src/main.rs` (and the now-empty `src/`)
|
||
- Create: `crates/domain/Cargo.toml`, `crates/domain/src/lib.rs`
|
||
- Create: `crates/db/Cargo.toml`, `crates/db/src/lib.rs`
|
||
- Create: `crates/api/Cargo.toml`, `crates/api/src/lib.rs`
|
||
- Create: `crates/server/Cargo.toml`, `crates/server/src/main.rs`
|
||
|
||
- [ ] **Step 1: Replace the root `Cargo.toml` with a virtual workspace**
|
||
|
||
Overwrite `Cargo.toml` with exactly:
|
||
|
||
```toml
|
||
[workspace]
|
||
resolver = "3"
|
||
members = ["crates/domain", "crates/db", "crates/api", "crates/server"]
|
||
|
||
[workspace.package]
|
||
edition = "2024"
|
||
rust-version = "1.85"
|
||
|
||
[workspace.dependencies]
|
||
tokio = { version = "1", features = ["full"] }
|
||
axum = "0.8"
|
||
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "macros"] }
|
||
uuid = { version = "1", features = ["v4", "serde"] }
|
||
serde = { version = "1", features = ["derive"] }
|
||
serde_json = "1"
|
||
clap = { version = "4", features = ["derive", "env"] }
|
||
utoipa = { version = "5", features = ["uuid"] }
|
||
anyhow = "1"
|
||
thiserror = "2"
|
||
tracing = "0.1"
|
||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||
tower = { version = "0.5", features = ["util"] }
|
||
http-body-util = "0.1"
|
||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||
```
|
||
|
||
- [ ] **Step 2: Remove the placeholder binary**
|
||
|
||
Run: `git rm src/main.rs`
|
||
Expected: `rm 'src/main.rs'`. The `src/` directory is now empty (removed).
|
||
|
||
- [ ] **Step 3: Create the `domain` crate (empty for now)**
|
||
|
||
`crates/domain/Cargo.toml`:
|
||
|
||
```toml
|
||
[package]
|
||
name = "domain"
|
||
version = "0.0.0"
|
||
edition.workspace = true
|
||
rust-version.workspace = true
|
||
|
||
[dependencies]
|
||
uuid.workspace = true
|
||
serde.workspace = true
|
||
```
|
||
|
||
`crates/domain/src/lib.rs`:
|
||
|
||
```rust
|
||
//! Core domain types and invariants. No I/O dependencies.
|
||
```
|
||
|
||
- [ ] **Step 4: Create the `db` crate (empty for now)**
|
||
|
||
`crates/db/Cargo.toml`:
|
||
|
||
```toml
|
||
[package]
|
||
name = "db"
|
||
version = "0.0.0"
|
||
edition.workspace = true
|
||
rust-version.workspace = true
|
||
|
||
[dependencies]
|
||
sqlx.workspace = true
|
||
thiserror.workspace = true
|
||
|
||
[dev-dependencies]
|
||
tokio.workspace = true
|
||
```
|
||
|
||
`crates/db/src/lib.rs`:
|
||
|
||
```rust
|
||
//! Database access. All SQL lives in this crate.
|
||
```
|
||
|
||
- [ ] **Step 5: Create the `api` crate (empty for now)**
|
||
|
||
`crates/api/Cargo.toml`:
|
||
|
||
```toml
|
||
[package]
|
||
name = "api"
|
||
version = "0.0.0"
|
||
edition.workspace = true
|
||
rust-version.workspace = true
|
||
|
||
[dependencies]
|
||
axum.workspace = true
|
||
serde.workspace = true
|
||
utoipa.workspace = true
|
||
db = { path = "../db" }
|
||
|
||
[dev-dependencies]
|
||
tokio.workspace = true
|
||
tower.workspace = true
|
||
http-body-util.workspace = true
|
||
serde_json.workspace = true
|
||
sqlx.workspace = true
|
||
```
|
||
|
||
`crates/api/src/lib.rs`:
|
||
|
||
```rust
|
||
//! HTTP API: router, handlers, and OpenAPI document.
|
||
```
|
||
|
||
- [ ] **Step 6: Create the `server` crate with a placeholder main**
|
||
|
||
`crates/server/Cargo.toml`:
|
||
|
||
```toml
|
||
[package]
|
||
name = "server"
|
||
version = "0.0.0"
|
||
edition.workspace = true
|
||
rust-version.workspace = true
|
||
|
||
[lib]
|
||
path = "src/lib.rs"
|
||
|
||
[[bin]]
|
||
name = "server"
|
||
path = "src/main.rs"
|
||
|
||
[dependencies]
|
||
tokio.workspace = true
|
||
axum.workspace = true
|
||
clap.workspace = true
|
||
anyhow.workspace = true
|
||
tracing.workspace = true
|
||
tracing-subscriber.workspace = true
|
||
api = { path = "../api" }
|
||
db = { path = "../db" }
|
||
|
||
[dev-dependencies]
|
||
reqwest.workspace = true
|
||
serde_json.workspace = true
|
||
api = { path = "../api" }
|
||
db = { path = "../db" }
|
||
sqlx.workspace = true
|
||
```
|
||
|
||
`crates/server/src/lib.rs`:
|
||
|
||
```rust
|
||
//! Server wiring: configuration and startup.
|
||
```
|
||
|
||
`crates/server/src/main.rs`:
|
||
|
||
```rust
|
||
fn main() {
|
||
println!("placeholder");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Verify the workspace builds**
|
||
|
||
Run: `cargo build --workspace`
|
||
Expected: compiles successfully; four crates (`domain`, `db`, `api`, `server`) build with warnings only about unused placeholders being acceptable.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore: replace placeholder package with role-named workspace"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: `domain` — newtype IDs
|
||
|
||
**Files:**
|
||
- Create: `crates/domain/src/id.rs`
|
||
- Modify: `crates/domain/src/lib.rs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `crates/domain/src/id.rs`:
|
||
|
||
```rust
|
||
use std::fmt;
|
||
use std::str::FromStr;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use uuid::Uuid;
|
||
|
||
/// Identifier for an organization (tenant).
|
||
///
|
||
/// A newtype over [`Uuid`] so it can never be confused with another entity's id.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
#[serde(transparent)]
|
||
pub struct OrgId(Uuid);
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn parses_and_displays_round_trip() {
|
||
let text = "550e8400-e29b-41d4-a716-446655440000";
|
||
let id: OrgId = text.parse().expect("valid uuid should parse");
|
||
assert_eq!(id.to_string(), text);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_invalid_uuid() {
|
||
assert!("not-a-uuid".parse::<OrgId>().is_err());
|
||
}
|
||
}
|
||
```
|
||
|
||
Wire it into `crates/domain/src/lib.rs`:
|
||
|
||
```rust
|
||
//! Core domain types and invariants. No I/O dependencies.
|
||
|
||
mod id;
|
||
|
||
pub use id::OrgId;
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `cargo test -p domain`
|
||
Expected: FAIL — compile error, `FromStr`/`Display` not implemented for `OrgId` (and `parse` unavailable).
|
||
|
||
- [ ] **Step 3: Implement the newtype API**
|
||
|
||
Append to `crates/domain/src/id.rs`, immediately after the `pub struct OrgId(Uuid);` line (before the `#[cfg(test)]` module):
|
||
|
||
```rust
|
||
impl OrgId {
|
||
/// Generate a fresh random id.
|
||
pub fn new() -> Self {
|
||
Self(Uuid::new_v4())
|
||
}
|
||
|
||
/// Wrap an existing [`Uuid`].
|
||
pub fn from_uuid(uuid: Uuid) -> Self {
|
||
Self(uuid)
|
||
}
|
||
|
||
/// The underlying [`Uuid`].
|
||
pub fn as_uuid(&self) -> Uuid {
|
||
self.0
|
||
}
|
||
}
|
||
|
||
impl Default for OrgId {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
impl fmt::Display for OrgId {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
write!(f, "{}", self.0)
|
||
}
|
||
}
|
||
|
||
impl FromStr for OrgId {
|
||
type Err = uuid::Error;
|
||
|
||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||
Ok(Self(Uuid::parse_str(s)?))
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `cargo test -p domain`
|
||
Expected: PASS — 2 tests (`parses_and_displays_round_trip`, `rejects_invalid_uuid`).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add crates/domain
|
||
git commit -m "feat(domain): add OrgId newtype"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: `server` — configuration via clap derive
|
||
|
||
**Files:**
|
||
- Create: `crates/server/src/config.rs`
|
||
- Modify: `crates/server/src/lib.rs`
|
||
- Test: `crates/server/tests/config.rs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `crates/server/tests/config.rs`:
|
||
|
||
```rust
|
||
use clap::Parser;
|
||
use server::Config;
|
||
|
||
#[test]
|
||
fn parses_from_args_with_defaults() {
|
||
let cfg = Config::try_parse_from([
|
||
"server",
|
||
"--database-url",
|
||
"postgres://localhost/test",
|
||
])
|
||
.expect("should parse");
|
||
|
||
assert_eq!(cfg.database_url, "postgres://localhost/test");
|
||
assert_eq!(cfg.bind_addr, "0.0.0.0:8080");
|
||
assert_eq!(cfg.app_name, "Collection Management System");
|
||
}
|
||
|
||
#[test]
|
||
fn database_url_is_required() {
|
||
assert!(Config::try_parse_from(["server"]).is_err());
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `cargo test -p server --test config`
|
||
Expected: FAIL — `server::Config` does not exist.
|
||
|
||
- [ ] **Step 3: Implement the config and export it**
|
||
|
||
Create `crates/server/src/config.rs`:
|
||
|
||
```rust
|
||
use clap::Parser;
|
||
|
||
/// Runtime configuration, sourced from CLI arguments and environment variables.
|
||
#[derive(Debug, Clone, Parser)]
|
||
#[command(version, about = "Collection management system server")]
|
||
pub struct Config {
|
||
/// PostgreSQL connection string.
|
||
#[arg(long, env = "DATABASE_URL")]
|
||
pub database_url: String,
|
||
|
||
/// Address to bind the HTTP server to.
|
||
#[arg(long, env = "BIND_ADDR", default_value = "0.0.0.0:8080")]
|
||
pub bind_addr: String,
|
||
|
||
/// User-facing application name (OpenAPI title, page title, …).
|
||
///
|
||
/// Defaults to a neutral name; set this to the real product name at deploy
|
||
/// time. The product name must never be hardcoded in source.
|
||
#[arg(long, env = "APP_NAME", default_value = "Collection Management System")]
|
||
pub app_name: String,
|
||
}
|
||
```
|
||
|
||
Replace `crates/server/src/lib.rs` with:
|
||
|
||
```rust
|
||
//! Server wiring: configuration and startup.
|
||
|
||
mod config;
|
||
|
||
pub use config::Config;
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `cargo test -p server --test config`
|
||
Expected: PASS — 2 tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add crates/server
|
||
git commit -m "feat(server): add clap-derive Config (args + env)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: `db` — connection pool and readiness ping
|
||
|
||
**Files:**
|
||
- Modify: `crates/db/src/lib.rs`
|
||
- Test: `crates/db/tests/ping.rs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `crates/db/tests/ping.rs`:
|
||
|
||
```rust
|
||
use db::Db;
|
||
use sqlx::PgPool;
|
||
|
||
#[sqlx::test]
|
||
async fn ping_succeeds(pool: PgPool) {
|
||
let db = Db::from_pool(pool);
|
||
db.ping()
|
||
.await
|
||
.expect("ping should succeed against a live database");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `cargo test -p db`
|
||
Expected: FAIL — `Db`, `Db::from_pool`, and `Db::ping` do not exist (compile error).
|
||
|
||
- [ ] **Step 3: Implement the `Db` handle**
|
||
|
||
Replace `crates/db/src/lib.rs` with:
|
||
|
||
```rust
|
||
//! Database access. All SQL lives in this crate.
|
||
|
||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||
|
||
/// A handle to the organization's PostgreSQL database.
|
||
#[derive(Clone)]
|
||
pub struct Db {
|
||
pool: PgPool,
|
||
}
|
||
|
||
impl Db {
|
||
/// Connect to the database at `database_url`, opening a connection pool.
|
||
pub async fn connect(database_url: &str) -> Result<Self, sqlx::Error> {
|
||
let pool = PgPoolOptions::new()
|
||
.max_connections(5)
|
||
.connect(database_url)
|
||
.await?;
|
||
Ok(Self { pool })
|
||
}
|
||
|
||
/// Build a handle from an existing pool (used in tests).
|
||
pub fn from_pool(pool: PgPool) -> Self {
|
||
Self { pool }
|
||
}
|
||
|
||
/// Borrow the underlying pool for repository modules.
|
||
pub fn pool(&self) -> &PgPool {
|
||
&self.pool
|
||
}
|
||
|
||
/// Readiness check: run a trivial query to confirm the database answers.
|
||
pub async fn ping(&self) -> Result<(), sqlx::Error> {
|
||
sqlx::query_scalar::<_, i32>("SELECT 1")
|
||
.fetch_one(&self.pool)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Ensure PostgreSQL is running and `DATABASE_URL` is set (see Prerequisites / Task 7).
|
||
Run: `cargo test -p db`
|
||
Expected: PASS — 1 test (`ping_succeeds`). `#[sqlx::test]` provisions and drops a temporary database automatically.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add crates/db
|
||
git commit -m "feat(db): add Db handle with pool connect and readiness ping"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: `api` — health probes, OpenAPI doc, and router
|
||
|
||
**Files:**
|
||
- Create: `crates/api/src/health.rs`
|
||
- Create: `crates/api/src/openapi.rs`
|
||
- Modify: `crates/api/src/lib.rs`
|
||
- Test: `crates/api/tests/health.rs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `crates/api/tests/health.rs`:
|
||
|
||
```rust
|
||
use api::{AppState, build_app};
|
||
use axum::body::Body;
|
||
use axum::http::{Request, StatusCode};
|
||
use http_body_util::BodyExt;
|
||
use sqlx::PgPool;
|
||
use tower::ServiceExt; // for `oneshot`
|
||
|
||
fn state(pool: PgPool, app_name: &str) -> AppState {
|
||
AppState {
|
||
db: db::Db::from_pool(pool),
|
||
app_name: app_name.to_string(),
|
||
}
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn live_returns_ok(pool: PgPool) {
|
||
let app = build_app(state(pool, "Test"));
|
||
let resp = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/health/live")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
assert_eq!(resp.status(), StatusCode::OK);
|
||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||
assert_eq!(json["status"], "ok");
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn ready_reports_database_true(pool: PgPool) {
|
||
let app = build_app(state(pool, "Test"));
|
||
let resp = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/health/ready")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
assert_eq!(resp.status(), StatusCode::OK);
|
||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||
assert_eq!(json["database"], true);
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn openapi_doc_uses_configured_title(pool: PgPool) {
|
||
let app = build_app(state(pool, "My Museum CMS"));
|
||
let resp = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api-docs/openapi.json")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
assert_eq!(resp.status(), StatusCode::OK);
|
||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||
assert_eq!(json["info"]["title"], "My Museum CMS");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `cargo test -p api --test health`
|
||
Expected: FAIL — `AppState`, `build_app`, and the routes do not exist (compile error).
|
||
|
||
- [ ] **Step 3: Implement health handlers**
|
||
|
||
Create `crates/api/src/health.rs`:
|
||
|
||
```rust
|
||
use axum::{
|
||
Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::get,
|
||
};
|
||
use serde::Serialize;
|
||
use utoipa::ToSchema;
|
||
|
||
use crate::AppState;
|
||
|
||
/// Liveness payload: the process is running.
|
||
#[derive(Serialize, ToSchema)]
|
||
pub(crate) struct Live {
|
||
/// Always `"ok"` when the process serves requests.
|
||
pub status: &'static str,
|
||
}
|
||
|
||
/// Readiness payload: dependencies were checked.
|
||
#[derive(Serialize, ToSchema)]
|
||
pub(crate) struct Ready {
|
||
/// `"ok"` when ready, `"degraded"` otherwise.
|
||
pub status: &'static str,
|
||
/// Whether the database responded to a ping.
|
||
pub database: bool,
|
||
}
|
||
|
||
/// Liveness probe — no dependencies checked.
|
||
#[utoipa::path(get, path = "/health/live", responses((status = 200, body = Live)))]
|
||
pub(crate) async fn live() -> Json<Live> {
|
||
Json(Live { status: "ok" })
|
||
}
|
||
|
||
/// Readiness probe — confirms the database answers.
|
||
#[utoipa::path(
|
||
get,
|
||
path = "/health/ready",
|
||
responses(
|
||
(status = 200, body = Ready, description = "Ready"),
|
||
(status = 503, body = Ready, description = "A dependency is unavailable")
|
||
)
|
||
)]
|
||
pub(crate) async fn ready(State(state): State<AppState>) -> impl IntoResponse {
|
||
match state.db.ping().await {
|
||
Ok(()) => (
|
||
StatusCode::OK,
|
||
Json(Ready {
|
||
status: "ok",
|
||
database: true,
|
||
}),
|
||
),
|
||
Err(_) => (
|
||
StatusCode::SERVICE_UNAVAILABLE,
|
||
Json(Ready {
|
||
status: "degraded",
|
||
database: false,
|
||
}),
|
||
),
|
||
}
|
||
}
|
||
|
||
/// Health routes, parameterized over [`AppState`].
|
||
pub(crate) fn routes() -> Router<AppState> {
|
||
Router::new()
|
||
.route("/health/live", get(live))
|
||
.route("/health/ready", get(ready))
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement the OpenAPI document and route**
|
||
|
||
Create `crates/api/src/openapi.rs`:
|
||
|
||
```rust
|
||
use axum::{Json, Router, extract::State, routing::get};
|
||
use utoipa::OpenApi;
|
||
|
||
use crate::{AppState, health};
|
||
|
||
#[derive(OpenApi)]
|
||
#[openapi(
|
||
paths(health::live, health::ready),
|
||
components(schemas(health::Live, health::Ready)),
|
||
info(title = "Collection Management System", version = "0.0.0")
|
||
)]
|
||
struct ApiDoc;
|
||
|
||
/// Serve the OpenAPI document, overriding the title from runtime config so the
|
||
/// product name is never hardcoded.
|
||
async fn openapi_json(State(state): State<AppState>) -> Json<utoipa::openapi::OpenApi> {
|
||
let mut doc = ApiDoc::openapi();
|
||
doc.info.title = state.app_name.clone();
|
||
Json(doc)
|
||
}
|
||
|
||
/// OpenAPI routes, parameterized over [`AppState`].
|
||
pub(crate) fn routes() -> Router<AppState> {
|
||
Router::new().route("/api-docs/openapi.json", get(openapi_json))
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Implement `AppState` and `build_app`**
|
||
|
||
Replace `crates/api/src/lib.rs` with:
|
||
|
||
```rust
|
||
//! HTTP API: router, handlers, and OpenAPI document.
|
||
|
||
mod health;
|
||
mod openapi;
|
||
|
||
use axum::Router;
|
||
use db::Db;
|
||
|
||
/// Shared application state passed to handlers.
|
||
#[derive(Clone)]
|
||
pub struct AppState {
|
||
/// Database handle for this organization.
|
||
pub db: Db,
|
||
/// User-facing product name (from config). Never hardcoded.
|
||
pub app_name: String,
|
||
}
|
||
|
||
/// Build the application router from shared state.
|
||
pub fn build_app(state: AppState) -> Router {
|
||
Router::new()
|
||
.merge(health::routes())
|
||
.merge(openapi::routes())
|
||
.with_state(state)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run the tests to verify they pass**
|
||
|
||
Run: `cargo test -p api --test health`
|
||
Expected: PASS — 3 tests (`live_returns_ok`, `ready_reports_database_true`, `openapi_doc_uses_configured_title`).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add crates/api
|
||
git commit -m "feat(api): add health probes, OpenAPI doc, and router"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: `server` — wire dependencies and serve
|
||
|
||
**Files:**
|
||
- Modify: `crates/server/src/lib.rs`
|
||
- Modify: `crates/server/src/main.rs`
|
||
- Test: `crates/server/tests/serve.rs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `crates/server/tests/serve.rs`:
|
||
|
||
```rust
|
||
use std::net::SocketAddr;
|
||
|
||
use api::AppState;
|
||
use db::Db;
|
||
use server::serve;
|
||
use tokio::net::TcpListener;
|
||
|
||
#[tokio::test]
|
||
async fn serves_health_live_over_tcp() {
|
||
let database_url =
|
||
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for this test");
|
||
let db = Db::connect(&database_url).await.expect("connect to database");
|
||
let state = AppState {
|
||
db,
|
||
app_name: "Test".to_string(),
|
||
};
|
||
|
||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||
let addr: SocketAddr = listener.local_addr().unwrap();
|
||
|
||
let handle = tokio::spawn(async move {
|
||
serve(listener, state).await.unwrap();
|
||
});
|
||
|
||
let url = format!("http://{addr}/health/live");
|
||
let body: serde_json::Value = reqwest::get(&url)
|
||
.await
|
||
.expect("request succeeds")
|
||
.json()
|
||
.await
|
||
.expect("json body");
|
||
assert_eq!(body["status"], "ok");
|
||
|
||
handle.abort();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `cargo test -p server --test serve`
|
||
Expected: FAIL — `server::serve` does not exist (compile error).
|
||
|
||
- [ ] **Step 3: Implement `run` and `serve`**
|
||
|
||
Replace `crates/server/src/lib.rs` with:
|
||
|
||
```rust
|
||
//! Server wiring: configuration and startup.
|
||
|
||
mod config;
|
||
|
||
pub use config::Config;
|
||
|
||
use anyhow::Context;
|
||
use api::{AppState, build_app};
|
||
use db::Db;
|
||
use tokio::net::TcpListener;
|
||
|
||
/// Connect dependencies from `config` and serve until shutdown.
|
||
pub async fn run(config: Config) -> anyhow::Result<()> {
|
||
let db = Db::connect(&config.database_url)
|
||
.await
|
||
.context("connecting to the database")?;
|
||
let state = AppState {
|
||
db,
|
||
app_name: config.app_name.clone(),
|
||
};
|
||
|
||
let listener = TcpListener::bind(&config.bind_addr)
|
||
.await
|
||
.with_context(|| format!("binding to {}", config.bind_addr))?;
|
||
tracing::info!(addr = %config.bind_addr, "server listening");
|
||
|
||
serve(listener, state).await
|
||
}
|
||
|
||
/// Serve the API on an already-bound listener (used by `run` and tests).
|
||
pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> {
|
||
let app = build_app(state);
|
||
axum::serve(listener, app)
|
||
.await
|
||
.context("running the HTTP server")?;
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement the binary entrypoint**
|
||
|
||
Replace `crates/server/src/main.rs` with:
|
||
|
||
```rust
|
||
use clap::Parser;
|
||
use server::{Config, run};
|
||
|
||
#[tokio::main]
|
||
async fn main() -> anyhow::Result<()> {
|
||
tracing_subscriber::fmt()
|
||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||
.init();
|
||
|
||
let config = Config::parse();
|
||
run(config).await
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run the test to verify it passes**
|
||
|
||
Ensure PostgreSQL is running and `DATABASE_URL` is set.
|
||
Run: `cargo test -p server --test serve`
|
||
Expected: PASS — 1 test (`serves_health_live_over_tcp`).
|
||
|
||
- [ ] **Step 6: Run the whole workspace test suite**
|
||
|
||
Run: `cargo test --workspace`
|
||
Expected: PASS — all tests across `domain`, `db`, `api`, `server`.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add crates/server
|
||
git commit -m "feat(server): wire config, database, and HTTP serving"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Developer ergonomics (local Postgres, task runner, env)
|
||
|
||
**Files:**
|
||
- Create: `docker-compose.yml`
|
||
- Create: `.env.example`
|
||
- Create: `justfile`
|
||
- Modify: `.gitignore`
|
||
|
||
- [ ] **Step 1: Add a local PostgreSQL service**
|
||
|
||
Create `docker-compose.yml`:
|
||
|
||
```yaml
|
||
services:
|
||
postgres:
|
||
image: postgres:17
|
||
environment:
|
||
POSTGRES_USER: postgres
|
||
POSTGRES_PASSWORD: postgres
|
||
POSTGRES_DB: cms_dev
|
||
ports:
|
||
- "5432:5432"
|
||
volumes:
|
||
- pgdata:/var/lib/postgresql/data
|
||
|
||
volumes:
|
||
pgdata:
|
||
```
|
||
|
||
- [ ] **Step 2: Add an example environment file**
|
||
|
||
Create `.env.example`:
|
||
|
||
```dotenv
|
||
# Connection string for local development and tests.
|
||
# The role must be allowed to CREATE DATABASE (sqlx::test provisions temp DBs).
|
||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/cms_dev
|
||
BIND_ADDR=0.0.0.0:8080
|
||
APP_NAME=Collection Management System
|
||
```
|
||
|
||
- [ ] **Step 3: Add a task runner**
|
||
|
||
Create `justfile`:
|
||
|
||
```just
|
||
set dotenv-load
|
||
|
||
# Run the server (reads .env)
|
||
run:
|
||
cargo run -p server
|
||
|
||
# Run the full test suite
|
||
test:
|
||
cargo test --workspace
|
||
|
||
# Format with the nightly toolchain
|
||
fmt:
|
||
cargo +nightly fmt
|
||
|
||
# Lint, treating warnings as errors
|
||
lint:
|
||
cargo clippy --workspace --all-targets -- -D warnings
|
||
|
||
# Format, lint, and test
|
||
check: fmt lint test
|
||
```
|
||
|
||
- [ ] **Step 4: Ignore the local env file**
|
||
|
||
Append to `.gitignore` (a newline then):
|
||
|
||
```gitignore
|
||
.env
|
||
```
|
||
|
||
- [ ] **Step 5: Verify the local stack end to end**
|
||
|
||
Run:
|
||
```bash
|
||
docker compose up -d
|
||
cp .env.example .env
|
||
cargo +nightly fmt
|
||
cargo clippy --workspace --all-targets -- -D warnings
|
||
cargo test --workspace
|
||
```
|
||
Expected: containers start; formatting clean; clippy reports no warnings; all tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add docker-compose.yml .env.example justfile .gitignore
|
||
git commit -m "chore: add local Postgres, justfile, and env example"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review (completed)
|
||
|
||
**Spec coverage (foundation slice of `docs/specs/2026-06-02-mvp-architecture.md`):**
|
||
- §5 role-named workspace, dependency direction inward → Tasks 1–6 (domain has no I/O deps; api depends on db; server depends on api+db). ✓
|
||
- §13 product name never hardcoded; from config → Task 3 (`Config::app_name`) + Task 5 (OpenAPI title override). ✓
|
||
- §7 OpenAPI code-first via utoipa → Task 5. ✓
|
||
- §8 sqlx + SQL confined to `db` crate → Task 4 (only crate with SQL). ✓
|
||
- §12 self-host single binary, sensible defaults → Tasks 3, 6 (defaults for bind/app_name; one binary). ✓
|
||
- §19 testing: repositories integration-tested against Postgres; deterministic handler tests → Tasks 4–6. ✓
|
||
- §21 first scaffolding task = dissolve `biggus-dickus` package → Task 1. ✓
|
||
- Out of scope here (later plans): isolation/credentials (deployment), auth, the data model, search, audit, export. Not foundation gaps.
|
||
|
||
**Placeholder scan:** no `TODO`/`TBD`/"add error handling"/"similar to" — every step has concrete code and commands. ✓
|
||
|
||
**Type consistency:** `AppState { db: Db, app_name: String }` is constructed identically in Task 5 tests, Task 6 lib, and Task 6 test; `build_app`/`serve`/`Db::from_pool`/`Db::connect`/`Db::ping` signatures match across tasks; `Config` field names (`database_url`, `bind_addr`, `app_name`) are consistent between Task 3 and Task 6. ✓
|
||
|
||
---
|
||
|
||
## Notes for follow-on plans
|
||
|
||
- Add `storage`, `search`, `auth` crates to `members` when their plans begin.
|
||
- CI (gitea actions running `just check`) is intentionally deferred — add once a second plan lands.
|
||
- Licensing for the workspace is undecided; `[workspace.package]` deliberately omits `license` until chosen.
|