Step 4 — Your First Endpoint (Axum)

Now we make the project answer HTTP requests. Axum is a web framework built on Tokio and Tower (a middleware ecosystem). Its whole model is: a router maps paths to handler functions, and handlers are plain async fns.

A minimal server

Replace src/main.rs with:

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    // 1. Build the router: map GET / to the `root` handler.
    let app = Router::new().route("/", get(root));

    // 2. Bind a TCP listener.
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    // 3. Serve.
    println!("listening on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

async fn root() -> &'static str {
    "Tasks API is running"
}

Run it:

cargo run
# then, in another terminal:
curl http://localhost:3000
# → Tasks API is running

Three things to notice:

Returning JSON

A real API returns JSON. Define a type, derive Serialize, and wrap it in Json:

use axum::{routing::get, Json, Router};
use serde::Serialize;

#[derive(Serialize)]
struct Task {
    id: i64,
    title: String,
    done: bool,
}

async fn list_tasks() -> Json<Vec<Task>> {
    let tasks = vec![
        Task { id: 1, title: "Learn Axum".into(), done: true },
        Task { id: 2, title: "Ship the API".into(), done: false },
    ];
    Json(tasks)
}

Add it to the router:

let app = Router::new()
    .route("/", get(root))
    .route("/tasks", get(list_tasks));
curl http://localhost:3000/tasks
# → [{"id":1,"title":"Learn Axum","done":true}, ...]

#[derive(Serialize)] told the compiler to generate the struct-to-JSON code. Json(...) sets the Content-Type: application/json header and serializes the body. Done.

Extractors: reading input from the request

Handlers declare what they need as parameters, and Axum's extractors supply them. The three you'll use most:

use axum::extract::{Path, Query, Json};

// Path parameter: GET /tasks/{id}
async fn get_task(Path(id): Path<i64>) -> Json<Task> { /* ... */ }

// Query string: GET /tasks?done=true
#[derive(serde::Deserialize)]
struct Filter { done: Option<bool> }
async fn list_tasks(Query(filter): Query<Filter>) -> Json<Vec<Task>> { /* ... */ }

// JSON body: POST /tasks  { "title": "..." }
#[derive(serde::Deserialize)]
struct NewTask { title: String }
async fn create_task(Json(body): Json<NewTask>) -> Json<Task> { /* ... */ }

Register the path param with {id} syntax:

let app = Router::new()
    .route("/tasks", get(list_tasks).post(create_task))
    .route("/tasks/{id}", get(get_task));

Gotcha worth memorizing: Axum 0.7+ uses {id}, not the older :id. The old syntax compiles but panics at startup when the router is built. iKanban's coding guidelines call this out explicitly because a bad deploy silently rolls back on it.

Sharing state: the database pool (preview)

Handlers need access to shared things — most importantly the database pool. You attach it once with .with_state(...) and extract it with State:

use axum::extract::State;
use sqlx::PgPool;

async fn list_tasks(State(pool): State<PgPool>) -> Json<Vec<Task>> {
    // use `pool` to query — we'll fill this in next step
    Json(vec![])
}

// when building the app:
let app = Router::new()
    .route("/tasks", get(list_tasks))
    .with_state(pool);   // every handler can now extract State<PgPool>

PgPool is cheap to clone (it's an Arc inside), so Axum hands each request its own handle to the same underlying connections.

Where we are

You have a running server with JSON endpoints and you know how to read path params, query strings, and request bodies. The handlers return fake data — time to make them real. Next: Step 5 — Talking to Postgres with SQLx.