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:
#[tokio::main]is a macro that sets up the Tokio async runtime and letsmainbeasync. Without it, you can't.awaitanything.get(root)wires the HTTP GET method to your function. There'spost,put,delete, etc.- The handler returns
&'static str— a plain string — and Axum turns it into a200 OKresponse. That works because&strimplements theIntoResponsetrait from Step 2.
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.