Skip to content

Project: Build a Web API with Axum

In this project you’ll build a real web server: a small to-do API. Other programs (a website, a phone app, or just curl in your terminal) send it HTTP requests, and it answers with JSON. It’s the same kind of thing that powers the “back end” of most apps you use.

We’ll use axum, a popular, friendly web framework made by the Tokio team. It sits on top of async Rust and serde, so this project ties together a lot of what you’ve learned: structs, Result, smart pointers and shared state between threads. If you’ve done the To-Do List CLI, you’ll recognize the idea. This time the to-do list lives behind a web address.

When you’re done, your server will answer these requests:

Method and path What it does Success status
GET /todos List all to-dos 200 OK
GET /todos/{id} Get one to-do 200 OK, or 404 Not Found
POST /todos Create a to-do from JSON 201 Created
DELETE /todos/{id} Delete a to-do 204 No Content, or 404 Not Found

A to-do looks like this in JSON:

{"id":1,"title":"Buy milk","done":false}

One important note: a web server waits for requests forever and needs a network port, so it can’t run in the Rust Playground. The server code on this page is meant for your own computer with cargo run. Every step below was compiled and run, and the responses shown are real. Where a part of the program can run in the Playground, you’ll find a “Try it Yourself” button.

Create a new project and add four crates:

Terminal window
cargo new todo-api
cd todo-api
cargo add [email protected] tokio --features tokio/full
cargo add serde --features derive
cargo add serde_json

Your Cargo.toml now has something like this (the exact version numbers may be newer):

[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

What each one does:

  • axum handles HTTP: it reads requests and sends responses.
  • tokio is the async runtime that runs everything. See Async/Await.
  • serde and serde_json turn your structs into JSON and back. See JSON with Serde.

You’ll also need curl to send requests. It comes with macOS, Linux, and Windows 10 and later. (On Windows, type curl.exe in PowerShell, because plain curl means something else there.)

Replace src/main.rs with this:

use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(hello));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
async fn hello() -> &'static str {
"Hello, world!"
}

Three pieces work together here:

  • A handler is an async fn that answers a request. hello returns some text, and axum sends it back as the response.
  • The router connects paths to handlers. .route("/", get(hello)) means “when someone sends a GET request to /, call hello”.
  • The listener opens port 3000 on your own computer (127.0.0.1), and axum::serve starts answering requests. It runs until you stop it.

Run it with cargo run. The program prints one line and then waits:

Listening on http://127.0.0.1:3000

Leave it running, open a second terminal, and send it a request:

Terminal window
curl http://127.0.0.1:3000/
Hello, world!

The server doesn’t add a newline after the text, so your terminal prompt may appear right after Hello, world!. That’s normal. On this page, each response is shown on its own line. You can also open http://127.0.0.1:3000/ in your browser. Press Ctrl + C in the first terminal to stop the server. You need to stop and restart it every time you change the code.

APIs usually answer with JSON. In axum, you wrap any value that implements serde’s Serialize in Json(...), and axum turns it into JSON and sets the right content-type header:

use axum::{routing::get, Json, Router};
use serde::Serialize;
#[derive(Serialize)]
struct Todo {
id: u32,
title: String,
done: bool,
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(hello))
.route("/example", get(example_todo));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
async fn hello() -> &'static str {
"Hello, world!"
}
async fn example_todo() -> Json<Todo> {
let todo = Todo {
id: 1,
title: String::from("Learn axum"),
done: false,
};
Json(todo)
}

The -i flag tells curl to also show the status line and headers:

Terminal window
curl -i http://127.0.0.1:3000/example
HTTP/1.1 200 OK
content-type: application/json
content-length: 42
{"id":1,"title":"Learn axum","done":false}

(curl also prints a date header, which we leave out on this page.) The JSON keys are exactly the field names of the struct. You can check that JSON shape in the Playground, without a server, because it’s plain serde. This is also the NewTodo struct we’ll use in step 4 for incoming data:

use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize)]
struct Todo {
id: u32,
title: String,
done: bool,
}
#[derive(Debug, Deserialize)]
struct NewTodo {
title: String,
}
fn main() {
let todo = Todo { id: 1, title: String::from("Learn axum"), done: false };
println!("{}", serde_json::to_string(&todo).unwrap());
let list = vec![todo.clone(), Todo { id: 2, title: String::from("Buy milk"), done: true }];
println!("{}", serde_json::to_string(&list).unwrap());
let input: NewTodo = serde_json::from_str(r#"{"title":"Walk the dog"}"#).unwrap();
println!("{:?}", input);
let bad = serde_json::from_str::<NewTodo>(r#"{"name":"Walk the dog"}"#);
println!("{}", bad.unwrap_err());
}

Output:

{"id":1,"title":"Learn axum","done":false}
[{"id":1,"title":"Learn axum","done":false},{"id":2,"title":"Buy milk","done":true}]
NewTodo { title: "Walk the dog" }
missing field `title` at line 1 column 23
▶ Try it Yourself

A Vec<Todo> becomes a JSON array. That’s exactly what GET /todos will return.

In a path like /todos/2, the 2 changes from request to request. In axum 0.8 you mark that part of the route with curly braces, {id}, and read it in the handler with the Path extractor:

use axum::{extract::Path, routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/hello/{name}", get(hello_name))
.route("/add/{a}/{b}", get(add));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
async fn hello_name(Path(name): Path<String>) -> String {
format!("Hello, {name}!")
}
async fn add(Path((a, b)): Path<(i32, i32)>) -> String {
format!("{a} + {b} = {}", a + b)
}

An extractor is a handler parameter that pulls one piece out of the request. Path(name): Path<String> is a pattern: it unwraps the Path and gives you the String inside. With two parameters, you get a tuple.

Terminal window
curl http://127.0.0.1:3000/hello/Ana
curl http://127.0.0.1:3000/add/2/3
Hello, Ana!
2 + 3 = 5

Here’s a nice surprise. You said a and b are i32, so axum checks that for you. Send something that isn’t a number:

Terminal window
curl -i http://127.0.0.1:3000/add/2/x
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
content-length: 68
Invalid URL: Cannot parse value at index 1 with value `x` to a `i32`

Your handler never even ran. The types in your function signature are the validation.

To create a to-do, the client sends JSON in the request body. The Json extractor reads the body and deserializes it into your struct. The client only sends a title, so we use a separate NewTodo struct for the input:

use axum::{routing::post, Json, Router};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct Todo {
id: u32,
title: String,
done: bool,
}
#[derive(Deserialize)]
struct NewTodo {
title: String,
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/echo", post(echo_todo));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
async fn echo_todo(Json(input): Json<NewTodo>) -> Json<Todo> {
let todo = Todo {
id: 1,
title: input.title,
done: false,
};
Json(todo)
}

Use post(...) instead of get(...) in the route. With curl, -X POST sets the method, -H adds a header, and -d sends the body:

Terminal window
curl -X POST http://127.0.0.1:3000/echo -H "Content-Type: application/json" -d '{"title":"Buy milk"}'
{"id":1,"title":"Buy milk","done":false}

Again, axum checks the input before your code runs. If the JSON is missing title, you get 422 Unprocessable Entity:

Terminal window
curl -i -X POST http://127.0.0.1:3000/echo -H "Content-Type: application/json" -d '{"name":"Buy milk"}'
HTTP/1.1 422 Unprocessable Entity
content-type: text/plain; charset=utf-8
content-length: 99
Failed to deserialize the JSON body into the target type: missing field `title` at line 1 column 19

And if you forget the Content-Type header, you get 415 Unsupported Media Type with the message Expected request with `Content-Type: application/json` . That header is the most common thing to forget when testing with curl.

So far, each handler made up its data. A real API needs to remember to-dos between requests. Requests can arrive at the same time and are handled on different threads, so the list must be safe to share. That’s the job of the pair you met on the Concurrency page:

  • Mutex<Vec<Todo>> makes sure only one request changes the list at a time.
  • Arc<...> lets many handlers own a pointer to that same list (see Smart Pointers).

We give the type a short name with type Db = Arc<Mutex<Vec<Todo>>>;, hand it to the router with .with_state(db), and read it in handlers with the State extractor. axum clones the Arc (cheap: it only copies a pointer) for each request.

Here’s the complete program. Replace src/main.rs with it:

use std::sync::{Arc, Mutex};
use axum::{
extract::{Path, State},
http::StatusCode,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize)]
struct Todo {
id: u32,
title: String,
done: bool,
}
#[derive(Deserialize)]
struct NewTodo {
title: String,
}
// The "database": a list of to-dos that every request can share.
type Db = Arc<Mutex<Vec<Todo>>>;
#[tokio::main]
async fn main() {
let db: Db = Arc::new(Mutex::new(Vec::new()));
let app = Router::new()
.route("/todos", get(list_todos).post(create_todo))
.route("/todos/{id}", get(get_todo).delete(delete_todo))
.with_state(db);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
// GET /todos
async fn list_todos(State(db): State<Db>) -> Json<Vec<Todo>> {
let todos = db.lock().unwrap();
Json(todos.clone())
}
// GET /todos/{id}
async fn get_todo(State(db): State<Db>, Path(id): Path<u32>) -> Result<Json<Todo>, StatusCode> {
let todos = db.lock().unwrap();
match todos.iter().find(|todo| todo.id == id) {
Some(todo) => Ok(Json(todo.clone())),
None => Err(StatusCode::NOT_FOUND),
}
}
// POST /todos
async fn create_todo(State(db): State<Db>, Json(input): Json<NewTodo>) -> (StatusCode, Json<Todo>) {
let mut todos = db.lock().unwrap();
let id = todos.iter().map(|todo| todo.id).max().unwrap_or(0) + 1;
let todo = Todo {
id,
title: input.title,
done: false,
};
todos.push(todo.clone());
(StatusCode::CREATED, Json(todo))
}
// DELETE /todos/{id}
async fn delete_todo(State(db): State<Db>, Path(id): Path<u32>) -> StatusCode {
let mut todos = db.lock().unwrap();
let before = todos.len();
todos.retain(|todo| todo.id != id);
if todos.len() < before {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}

Let’s walk through the new ideas:

  • One path, several methods. get(list_todos).post(create_todo) sends GET /todos and POST /todos to different handlers.
  • Status codes are return values. StatusCode comes from axum’s http module. A handler can return a Result: Ok(Json(todo)) becomes 200 with JSON, and Err(StatusCode::NOT_FOUND) becomes an empty 404. A tuple (StatusCode::CREATED, Json(todo)) sets the status and the body. Returning only StatusCode::NO_CONTENT (204) means “done, nothing to send back”.
  • lock() and the guard. db.lock().unwrap() waits until no other request is using the list, then gives you a guard you can read and change like a normal Vec. The lock is released when the guard goes out of scope at the end of the function.
  • New ids. The next id is one more than the biggest id in the list, or 1 for an empty list.
  • Deleting. retain keeps every to-do whose id doesn’t match. If the length didn’t change, nothing was deleted, so we return 404.

Why std::sync::Mutex and not an async one? It’s fine (and faster) as long as you don’t hold the lock across an .await. None of our handlers do: they lock, work with the list, and return.

The list-and-lock logic doesn’t need a web server, so you can try it in the Playground. These functions do the same work as the handlers, without axum:

use std::sync::{Arc, Mutex};
#[derive(Clone, Debug)]
struct Todo {
id: u32,
title: String,
done: bool,
}
type Db = Arc<Mutex<Vec<Todo>>>;
fn create(db: &Db, title: &str) -> Todo {
let mut todos = db.lock().unwrap();
let id = todos.iter().map(|todo| todo.id).max().unwrap_or(0) + 1;
let todo = Todo { id, title: title.to_string(), done: false };
todos.push(todo.clone());
todo
}
fn get(db: &Db, id: u32) -> Option<Todo> {
let todos = db.lock().unwrap();
todos.iter().find(|todo| todo.id == id).cloned()
}
fn delete(db: &Db, id: u32) -> bool {
let mut todos = db.lock().unwrap();
let before = todos.len();
todos.retain(|todo| todo.id != id);
todos.len() < before
}
fn main() {
let db: Db = Arc::new(Mutex::new(Vec::new()));
let second_handle = Arc::clone(&db); // what axum does for each request
println!("{:?}", create(&db, "Buy milk"));
println!("{:?}", create(&second_handle, "Learn axum"));
println!("{:?}", get(&db, 2));
println!("{:?}", get(&db, 99));
println!("deleted 1? {}", delete(&db, 1));
println!("deleted 1 again? {}", delete(&db, 1));
println!("left: {}", db.lock().unwrap().len());
}

Output:

Todo { id: 1, title: "Buy milk", done: false }
Todo { id: 2, title: "Learn axum", done: false }
Some(Todo { id: 2, title: "Learn axum", done: false })
None
deleted 1? true
deleted 1 again? false
left: 1
▶ Try it Yourself

The to-do created through second_handle shows up when you read through db, because both Arcs point to the same list.

Start the server with cargo run, and in a second terminal, try every endpoint. At first the list is empty:

Terminal window
curl http://127.0.0.1:3000/todos
[]

Create a to-do. Notice the 201 Created status:

Terminal window
curl -i -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title":"Buy milk"}'
HTTP/1.1 201 Created
content-type: application/json
content-length: 40
{"id":1,"title":"Buy milk","done":false}

Create a second one and list them all:

Terminal window
curl -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title":"Learn axum"}'
curl http://127.0.0.1:3000/todos
{"id":2,"title":"Learn axum","done":false}
[{"id":1,"title":"Buy milk","done":false},{"id":2,"title":"Learn axum","done":false}]

Get one by id, and ask for one that doesn’t exist:

Terminal window
curl http://127.0.0.1:3000/todos/2
curl -i http://127.0.0.1:3000/todos/99
{"id":2,"title":"Learn axum","done":false}
HTTP/1.1 404 Not Found
content-length: 0

Delete to-do 1, then try to delete it again:

Terminal window
curl -i -X DELETE http://127.0.0.1:3000/todos/1
curl -i -X DELETE http://127.0.0.1:3000/todos/1
HTTP/1.1 204 No Content
HTTP/1.1 404 Not Found
content-length: 0
Terminal window
curl http://127.0.0.1:3000/todos
[{"id":2,"title":"Learn axum","done":false}]

axum also handles some mistakes for you. A wrong type in the JSON gives a 422 that explains the problem, and a method you didn’t set up for a path gives 405 Method Not Allowed, with a header listing the methods that do work:

Terminal window
curl -i -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title": 42}'
curl -i -X PUT http://127.0.0.1:3000/todos/2
HTTP/1.1 422 Unprocessable Entity
content-type: text/plain; charset=utf-8
content-length: 130
Failed to deserialize the JSON body into the target type: title: invalid type: integer `42`, expected a string at line 1 column 12
HTTP/1.1 405 Method Not Allowed
allow: GET,HEAD,DELETE
content-length: 0

Stop the server with Ctrl + C and start it again: the list is empty. The data lives only in memory. Saving it is the first idea below.

Using the old :id route syntax. Many tutorials online were written for axum 0.7, which used /todos/:id. In axum 0.8 this compiles, but the program panics as soon as it starts:

thread 'main' (12345) panicked at src/main.rs:32:10:
Path segments must not start with `:`. For capture groups, use `{capture}`. If you meant to literally match a segment starting with a colon, call `without_v07_checks` on the router.

The fix: write /todos/{id}.

Putting Json before other extractors. The request body can only be read once, so the extractor that reads it (Json) must be the last parameter. If you write create_todo(Json(input): Json<NewTodo>, State(db): State<Db>), the error doesn’t say that directly. The important lines are:

error[E0277]: the trait bound `fn(Json<NewTodo>, State<...>) -> ... {create_todo}: Handler<_, ...>` is not satisfied
|
31 | .route("/todos", get(list_todos).post(create_todo))
| ---- ^^^^^^^^^^^ unsatisfied trait bound
|
= note: Consider using `#[axum::debug_handler]` to improve the error message

When you see “Handler is not satisfied”, check the order of your parameters, and check that every return type is something axum can turn into a response.

Forgetting -H "Content-Type: application/json" in curl. The server replies 415. The header is required for the Json extractor.

Forgetting to restart. The running server is the old program. Stop it with Ctrl + C and cargo run again after every change.

Once the basic API works, here are good next steps. Try them one at a time.

  • Mark a to-do as done. Add PATCH /todos/{id} with .patch(...) on the second route. Find the to-do with iter_mut(), set done = true, and return it.
  • Validation. An empty title shouldn’t be allowed. A handler can return Result<(StatusCode, Json<Todo>), (StatusCode, String)>, and send back Err((StatusCode::BAD_REQUEST, String::from("title must not be empty"))). Use title.trim().is_empty() to check.
  • Ids that never repeat. With “biggest id plus one”, deleting the newest to-do lets its id be used again. Keep a separate counter in your state instead, for example a struct with todos: Vec<Todo> and next_id: u32 inside the Mutex.
  • Persistence with a JSON file. Derive Deserialize on Todo too. At startup, read todos.json with std::fs::read_to_string and serde_json::from_str (or start empty if the file doesn’t exist). After every change, write the list back with serde_json::to_string_pretty and std::fs::write. The File I/O and JSON with Serde pages have everything you need. Later, you can swap the file for a real database such as SQLite.
  • Logging with tower-http. Run cargo add tower-http --features trace and cargo add tracing-subscriber, then add a logging layer. The server now prints a line for each request, with the method, path, status, and how long it took:
use tower_http::trace::TraceLayer;
// at the start of main:
tracing_subscriber::fmt()
.with_max_level(tracing_subscriber::filter::LevelFilter::DEBUG)
.init();
// when building the router:
let app = Router::new()
.route("/todos", get(list_todos).post(create_todo))
.route("/todos/{id}", get(get_todo).delete(delete_todo))
.layer(TraceLayer::new_for_http())
.with_state(db);
  • Deployment notes. 127.0.0.1 only accepts requests from your own computer. To accept requests from other machines (for example inside Docker or on a server), bind to 0.0.0.0 instead. Many hosting services tell your program which port to use through a PORT environment variable, which you can read with std::env::var (see Environment Variables and Processes). Build with cargo build --release for a much faster server. The result is a single file in target/release/ that you can copy to a Linux server, or put in a small Docker image. For a public site, put it behind a reverse proxy (such as Caddy or nginx) that handles HTTPS.
  • A handler is an async fn. A router maps a method and path to a handler. axum::serve runs it on a tokio TcpListener.
  • Extractors pull data from the request: Path for parts of the URL (/todos/{id}), Json for the body (always last), and State for shared data.
  • Return Json(value) for JSON, StatusCode for a status, a tuple to set both, or a Result to choose between success and an error status.
  • Arc<Mutex<Vec<Todo>>> shares one list safely between all requests. Lock it, use it, and let the guard drop. Don’t hold it across .await.
  • Test your API with curl: -i shows the status, -X sets the method, -H adds a header, and -d sends a body.

Quick check

1. In axum 0.8, how do you write a route with an id in the path?

2. A handler returns (StatusCode::CREATED, Json(todo)). What does the client receive?

3. Why is the shared list wrapped in Arc<Mutex<...>>?

4. Which extractor must come last in a handler's parameters?

5. You curl -X POST some JSON but forget the Content-Type: application/json header. What does axum reply?

Score: 0 / 5