Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, and ControlError taxonomy) and control-store = { path = "../control-store" } (the Store), plus axum, tokio, serde, serde_json, chrono, uuid — all { workspace = true }.
      • axum (0.7) is the HTTP framework: the Router, the extractors (State, Path, Json), and the IntoResponse trait all come from here.
      • serde_json supplies the json! macro for the response bodies ({ "id": … }, { "error": … }).
      • uuid (feature v4) is needed to parse a path id string back into a RunId and to reject a malformed one as 400.
    • [dev-dependencies]: reqwest (features ["json"]), pretty_assertions, tokio — all { workspace = true }. reqwest is 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-control4 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.

→ Answer key

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.models is empty, return ControlError::Invalid("at least one model is required"). If body.epochs == 0, return ControlError::Invalid("epochs must be >= 1"). Each becomes a 400 through ApiError.
  • Build the Run. id: RunId::new(), status: RunStatus::Queued, created_at: Utc::now(), manifest from the body, and — the one computed field — job_count = body.models.len() as u32 * body.epochs (one job per model × epoch). done_count: 0. Move models and epochs in 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.

→ Answer key

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_run returns Result<Json<Run>, ApiError>. Parse the id, state.store.get_run(run_id).await?, and map the Option: Some(run)Json(run) (200), NoneControlError::NotFound(format!("run {id}")) (404).
  • get_results parses the id, then checks the run exists first (get_run(...).await?.is_none()NotFound) so a missing run is a 404 rather than a bare empty list, then returns Json(state.store.run_results(run_id).await?).

→ Answer key

Concepts exercised

  • A handler as an async fn whose extractor parameters and IntoResponse return type do the parsing and replying.
  • State<AppState> as injected shared state, wired by .with_state(...).
  • Path<String> and Json<CreateRun> extraction — and Json placed last because it consumes the body.
  • One IntoResponse impl as the single ControlError → status-code dispatch table.
  • Testing the real request path by spawning the server on 127.0.0.1:0 and driving it with reqwest.

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])

  1. Write the failing test. spawn(), POST /runs with json!({ "manifest": "m.jsonl", "models": ["claude"], "epochs": 2 }). Assert resp.status() == 201 and the returned body["id"].as_str() is longer than 10 chars.
  2. Predict: before implementing create_run, if you accidentally wrote the handler as async fn create_run(Json(body): Json<CreateRun>, State(state): State<AppState>)Json first — what does the compiler say, and does it name the real problem? (Recall the concept chapter's Handler<_, _> trap.)
  3. Run — it fails to compile (no app/handlers yet).
  4. Implement AppState, app, ApiError, CreateRun, and create_run. What each does is specified above; how you arrange it is yours.
  5. Run green, commit.

Test 2 — post_with_no_models_is_400 (#[tokio::test])

  1. Write the failing test. POST /runs with "models": [] and "epochs": 1. Assert resp.status() == 400.
  2. Predict: the empty-models check returns ControlError::Invalid. If your ApiError match were missing the Invalid arm and fell through to the _ => 500 catch-all, what status would this test see, and would create_run itself need changing to fix it — or only the one match?
  3. Run, confirm the Invalid → 400 mapping, adjust the ApiError match if needed.
  4. Run green, commit.

Test 3 — get_missing_run_is_404 (#[tokio::test])

  1. Write the failing test. GET /runs/{} for a fresh Uuid::new_v4() that was never posted. Assert resp.status() == 404.
  2. 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 back None. Which ControlError variant does the None branch produce, and which status does it map to — versus what a malformed id like /runs/not-a-uuid would produce instead?
  3. Run — fails (no get_run yet, or the None branch missing).
  4. Implement parse_run_id and get_run: parse, get_run, Some → Json, None → NotFound.
  5. Run green, commit.

Test 4 — get_run_after_post_returns_queued (#[tokio::test])

  1. Write the failing test. POST /runs with "models": ["a", "b"], "epochs": 3; pull id out of the response. Then GET /runs/{id} and assert run["status"] == "queued" and run["job_count"] == 6.
  2. Predict: why is job_count 6 and not 5 or 3? Trace models.len() * epochs. And why does status serialize as the lowercase string "queued" rather than "Queued" — which attribute on RunStatus decided that, back in the Core arc?
  3. Run — confirm the round trip: a run posted, persisted, and read back through the store with its computed job_count and snake_case status intact.
  4. Run green, commit.
Why 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.