Build: The Coordinator API
Maps to: Phase 3 (panoptes-control API). Kind: Build.
Objective
Give the coordinator a front door. Add the panoptes-control library target: an axum Router, an AppState carrying the store, an ApiError that maps the ControlError taxonomy to HTTP status codes in one place, and three handlers — create_run, get_run, get_results. By the end a client can POST a run, get a 201 with its id, and GET the run back as queued — all persisted through the store you built in Part IV, tested by spawning the real server on a random port and hitting it with reqwest.
Scaffold
Create (new crate — add crates/panoptes-control to the workspace members):
crates/panoptes-control/Cargo.toml[dependencies]:control-core = { path = "../control-core" }(the domain, ids, andControlErrortaxonomy) andcontrol-store = { path = "../control-store" }(theStore), plusaxum,tokio,serde,serde_json,chrono,uuid— all{ workspace = true }.axum(0.7) is the HTTP framework: theRouter, the extractors (State,Path,Json), and theIntoResponsetrait all come from here.serde_jsonsupplies thejson!macro for the response bodies ({ "id": … },{ "error": … }).uuid(featurev4) is needed to parse a path id string back into aRunIdand to reject a malformed one as400.
[dev-dependencies]:reqwest(features["json"]),pretty_assertions,tokio— all{ workspace = true }.reqwestis the test client that drives the spawned server.
crates/panoptes-control/src/lib.rs— the whole API surface:AppState,app(),ApiError,CreateRun, and the three handlers, plus the#[cfg(test)]module.
Dependencies this chapter exercises: axum (router, extractors, IntoResponse), control-store (the source of truth), control-core (the domain + error taxonomy), serde_json (the json! bodies), reqwest (dev — the test client hitting a spawned server).
Expected result: cargo test -p panoptes-control → 4 tests pass (post_run_returns_201_and_id, post_with_no_models_is_400, get_missing_run_is_404, get_run_after_post_returns_queued).
The spec (givens)
The Router, AppState, and app()
AppState holds the store and nothing else. It must be Clone — axum clones it into each request — and the store is cheap to clone (it wraps a connection pool behind an Arc).
#[derive(Clone)]
pub struct AppState {
pub store: Store,
}
pub fn app(state: AppState) -> Router {
Router::new()
.route("/runs", post(create_run))
.route("/runs/:id", get(get_run))
.route("/runs/:id/results", get(get_results))
.with_state(state)
}
Three routes, one handler each. :id is the path parameter get_run and get_results extract. .with_state(state) is what makes State<AppState> available to every handler; forget it and the router will not type-check against handlers that take State.
CreateRun, validation, and create_run
The request body deserializes into a private CreateRun:
// Request body → POST /runs
{ "manifest": "vignettes.jsonl", "models": ["claude", "gpt-4"], "epochs": 3 }
#[derive(Deserialize)]
struct CreateRun {
manifest: String,
models: Vec<String>,
epochs: u32,
}
create_run takes State(state): State<AppState> and Json(body): Json<CreateRun> — Json last, because it consumes the body (the concept chapter's trap). It validates, builds a Run, persists it, and returns 201:
- Validate. If
body.modelsis empty, returnControlError::Invalid("at least one model is required"). Ifbody.epochs == 0, returnControlError::Invalid("epochs must be >= 1"). Each becomes a400throughApiError. - Build the
Run.id: RunId::new(),status: RunStatus::Queued,created_at: Utc::now(),manifestfrom the body, and — the one computed field —job_count = body.models.len() as u32 * body.epochs(one job per model × epoch).done_count: 0. Movemodelsandepochsin from the body. - Persist, then answer.
state.store.insert_run(&run).await?, then return(StatusCode::CREATED, Json(json!({ "id": run.id.to_string() }))).into_response().
The response body is just the new id: { "id": "…uuid…" }. The ? on insert_run is where a store failure would turn into a 500 — through the same ApiError you are about to write.
ApiError — the taxonomy → status mapping, in one place
ApiError wraps a ControlError and implements IntoResponse once. This match is the only place in the crate a ControlError becomes a status code:
ControlError::NotFound(_) → 404 NOT_FOUND
ControlError::Invalid(_) → 400 BAD_REQUEST
_ (Worker/Protocol/Store/Io) → 500 INTERNAL_SERVER_ERROR
Provide impl From<ControlError> for ApiError so handlers can ?-propagate a ControlError straight into an ApiError. The response body on every error is Json(json!({ "error": self.0.to_string() })) — the error's Display, which the taxonomy already gives you.
get_run and get_results
Both take State(state) and Path(id): Path<String>. The id arrives as a String; parse it into a RunId first, and a malformed id is a 400, not a 404:
fn parse_run_id(id: &str) -> Result<RunId, ApiError> {
Uuid::from_str(id)
.map(RunId)
.map_err(|_| ControlError::Invalid(format!("bad run id {id:?}")).into())
}
get_runreturnsResult<Json<Run>, ApiError>. Parse the id,state.store.get_run(run_id).await?, and map theOption:Some(run)→Json(run)(200),None→ControlError::NotFound(format!("run {id}"))(404).get_resultsparses the id, then checks the run exists first (get_run(...).await?.is_none()→NotFound) so a missing run is a404rather than a bare empty list, then returnsJson(state.store.run_results(run_id).await?).
Concepts exercised
- A handler as an
async fnwhose extractor parameters andIntoResponsereturn type do the parsing and replying. State<AppState>as injected shared state, wired by.with_state(...).Path<String>andJson<CreateRun>extraction — andJsonplaced last because it consumes the body.- One
IntoResponseimpl as the singleControlError→ status-code dispatch table. - Testing the real request path by spawning the server on
127.0.0.1:0and driving it withreqwest.
The build loop (you drive)
The test module needs a helper. Write it once; all four tests use it:
// Spawn the app on a random port; return its base URL and the store, so a
// test can seed state before hitting the API.
async fn spawn() -> (String, Store) {
let store = Store::in_memory().await.unwrap();
let app = app(AppState { store: store.clone() });
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(format!("http://{addr}"), store)
}
Test 1 — post_run_returns_201_and_id (#[tokio::test])
- Write the failing test.
spawn(),POST /runswithjson!({ "manifest": "m.jsonl", "models": ["claude"], "epochs": 2 }). Assertresp.status() == 201and the returnedbody["id"].as_str()is longer than 10 chars. - Predict: before implementing
create_run, if you accidentally wrote the handler asasync fn create_run(Json(body): Json<CreateRun>, State(state): State<AppState>)—Jsonfirst — what does the compiler say, and does it name the real problem? (Recall the concept chapter'sHandler<_, _>trap.) - Run — it fails to compile (no
app/handlers yet). - Implement
AppState,app,ApiError,CreateRun, andcreate_run. What each does is specified above; how you arrange it is yours. - Run green, commit.
Test 2 — post_with_no_models_is_400 (#[tokio::test])
- Write the failing test.
POST /runswith"models": []and"epochs": 1. Assertresp.status() == 400. - Predict: the empty-models check returns
ControlError::Invalid. If yourApiErrormatchwere missing theInvalidarm and fell through to the_ => 500catch-all, what status would this test see, and wouldcreate_runitself need changing to fix it — or only the onematch? - Run, confirm the
Invalid → 400mapping, adjust theApiErrormatchif needed. - Run green, commit.
Test 3 — get_missing_run_is_404 (#[tokio::test])
- Write the failing test.
GET /runs/{}for a freshUuid::new_v4()that was never posted. Assertresp.status() == 404. - Predict: the id here is a valid UUID that simply is not in the store, so it reaches
get_run(run_id).await?and comes backNone. WhichControlErrorvariant does theNonebranch produce, and which status does it map to — versus what a malformed id like/runs/not-a-uuidwould produce instead? - Run — fails (no
get_runyet, or theNonebranch missing). - Implement
parse_run_idandget_run: parse,get_run,Some → Json,None → NotFound. - Run green, commit.
Test 4 — get_run_after_post_returns_queued (#[tokio::test])
- Write the failing test.
POST /runswith"models": ["a", "b"],"epochs": 3; pullidout of the response. ThenGET /runs/{id}and assertrun["status"] == "queued"andrun["job_count"] == 6. - Predict: why is
job_count6and not5or3? Tracemodels.len() * epochs. And why doesstatusserialize as the lowercase string"queued"rather than"Queued"— which attribute onRunStatusdecided that, back in the Core arc? - Run — confirm the round trip: a run posted, persisted, and read back through the store with its computed
job_countandsnake_casestatus intact. - Run green, commit.
get_results checks existence first
get_results could just return run_results(run_id) — but a missing run and a run with zero results would then look identical (both an empty list, both 200). Checking get_run(...).is_none() first turns "no such run" into an honest 404, distinct from "this run exists but has produced nothing yet" (200 with []). Same instinct as fetch_optional in the store: absence is a real state the type — and now the status code — should name.
Done when
cargo test -p panoptes-control shows 4 passing tests: a posted run comes back 201 with an id, empty models is 400, an unknown but valid id is 404, and a posted run reads back queued with job_count == models.len() * epochs. The ControlError → status mapping lives in exactly one match, and every test drove the real server over a real socket on a random port.