# Step 8 — The Route Layer: Axum Handlers for `/api/tags`

Step 7 gave us a `TagRepository` that talks to Postgres. Now `crates/remote/src/routes/tags.rs` exposes it over HTTP with [Axum](https://github.com/tokio-rs/axum), the async web framework the `rust-as-backend` tutorial (see the docs sidebar) covers from scratch. Here we focus on the parts a frontend engineer needs to read a Rust route file confidently.

## The router — Axum 0.8 path syntax

```rust
pub fn router() -> Router<AppState> {
    Router::new()
        .route("/tags", get(list_tags).post(create_tag))
        .route("/tags/{tag_id}", get(get_tag).put(update_tag).delete(delete_tag))
}
```

Two routes, five handlers. `{tag_id}` is Axum 0.8's path-parameter syntax — the frontend equivalent of react-router-dom's `:tag_id` from Step 6. (CLAUDE.md flags this explicitly: the older `:tag_id` syntax silently compiles but panics at server startup in Axum 0.8 — worth knowing if you ever add a route yourself.)

This `router()` function isn't called directly by the app — it's merged into the full app router in `crates/remote/src/routes/mod.rs`:

```rust
.merge(tags::router())
```

That one line is why `/api/tags` responds at all — a route file that exists but is never `.merge()`d into the app router would silently 404 on every request, which is why checking the mount point is a standard step (not just for this tutorial, but for CLAUDE.md's own "Gate 2f" review checklist) whenever a route seems to be missing in production.

## A handler, read top to bottom

```rust
async fn list_tags(
    State(state): State<AppState>,
    Extension(ctx): Extension<RequestContext>,
    Query(params): Query<ListTagsQuery>,
) -> Result<Json<ApiResponse<Vec<Tag>>>, ErrorResponse> {
    let team_id_or_slug = params.team_id
        .ok_or_else(|| ErrorResponse::new(StatusCode::BAD_REQUEST, "team_id is required"))?;

    let team = TeamRepository::get_by_id_or_slug(state.pool(), &team_id_or_slug)
        .await
        .map_err(|error| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to get team"))?
        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "team not found"))?;

    ensure_team_member_access(state.pool(), team.id, ctx.user.id).await?;

    let tags = TagRepository::find_by_team(state.pool(), team.id)
        .await
        .map_err(|error| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to list tags"))?;

    Ok(ApiResponse::success(tags))
}
```

Every function argument is an **extractor** — Axum pulls it out of the incoming request before your handler code runs at all:
- `State(state)` — shared app state (the DB pool).
- `Extension(ctx)` — the authenticated user, attached by upstream auth middleware (this is why the handler never has to check "is this request logged in?" itself — a request without valid auth never reaches this line).
- `Query(params)` — the `?team_id=...` query string, already parsed into `ListTagsQuery`.

The function body is a straight-line story: validate input exists → resolve the team → **check the requesting user actually has access to that team's workspace** (`ensure_team_member_access`) → fetch the data → wrap it in a success envelope. Every fallible step (`?`) short-circuits to a typed `ErrorResponse` with an explicit HTTP status — there's no unchecked `.unwrap()` that could panic the whole server on bad input.

## Checkpoint

Compare `list_tags` to `create_tag` in the same file: `create_tag` checks `ensure_team_member_access` *before* calling `TagRepository::create`, using the `team_id` from the incoming payload rather than from an existing row — because there's no existing tag yet to look the team up from. Notice how the authorization check is still there, just sourced from a different place depending on whether the resource already exists.

Continue to **Step 9 — The Shared Contract: `shared/types.ts`**, where the Rust `Tag` struct from Step 7 gets its TypeScript twin.
