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.
What you’ll build
Section titled “What you’ll build”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.
Setting up the project
Section titled “Setting up the project”Create a new project and add four crates:
cargo new todo-apicd todo-apicargo add serde --features derivecargo add serde_jsonYour 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.)
Step 1: Hello, world!
Section titled “Step 1: Hello, world!”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 fnthat answers a request.helloreturns some text, and axum sends it back as the response. - The router connects paths to handlers.
.route("/", get(hello))means “when someone sends aGETrequest to/, callhello”. - The listener opens port 3000 on your own computer (
127.0.0.1), andaxum::servestarts 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:3000Leave it running, open a second terminal, and send it a request:
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.
Step 2: Return JSON from a struct
Section titled “Step 2: Return JSON from a struct”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:
curl -i http://127.0.0.1:3000/exampleHTTP/1.1 200 OKcontent-type: application/jsoncontent-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 23A Vec<Todo> becomes a JSON array. That’s exactly what GET /todos will return.
Step 3: Path parameters
Section titled “Step 3: Path parameters”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.
curl http://127.0.0.1:3000/hello/Anacurl http://127.0.0.1:3000/add/2/3Hello, Ana!2 + 3 = 5Here’s a nice surprise. You said a and b are i32, so axum checks that for you. Send something that isn’t a number:
curl -i http://127.0.0.1:3000/add/2/xHTTP/1.1 400 Bad Requestcontent-type: text/plain; charset=utf-8content-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.
Step 4: Accept JSON with POST
Section titled “Step 4: Accept JSON with POST”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:
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:
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 Entitycontent-type: text/plain; charset=utf-8content-length: 99
Failed to deserialize the JSON body into the target type: missing field `title` at line 1 column 19And 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.
Step 5: Shared state for a real to-do API
Section titled “Step 5: Shared state for a real to-do API”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 /todosasync 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 /todosasync 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)sendsGET /todosandPOST /todosto different handlers. - Status codes are return values.
StatusCodecomes from axum’shttpmodule. A handler can return aResult:Ok(Json(todo))becomes 200 with JSON, andErr(StatusCode::NOT_FOUND)becomes an empty 404. A tuple(StatusCode::CREATED, Json(todo))sets the status and the body. Returning onlyStatusCode::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 normalVec. 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.
retainkeeps 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 })Nonedeleted 1? truedeleted 1 again? falseleft: 1The to-do created through second_handle shows up when you read through db, because both Arcs point to the same list.
Step 6: Test it with curl
Section titled “Step 6: Test it with curl”Start the server with cargo run, and in a second terminal, try every endpoint. At first the list is empty:
curl http://127.0.0.1:3000/todos[]Create a to-do. Notice the 201 Created status:
curl -i -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title":"Buy milk"}'HTTP/1.1 201 Createdcontent-type: application/jsoncontent-length: 40
{"id":1,"title":"Buy milk","done":false}Create a second one and list them all:
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:
curl http://127.0.0.1:3000/todos/2curl -i http://127.0.0.1:3000/todos/99{"id":2,"title":"Learn axum","done":false}HTTP/1.1 404 Not Foundcontent-length: 0Delete to-do 1, then try to delete it again:
curl -i -X DELETE http://127.0.0.1:3000/todos/1curl -i -X DELETE http://127.0.0.1:3000/todos/1HTTP/1.1 204 No Content
HTTP/1.1 404 Not Foundcontent-length: 0curl 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:
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/2HTTP/1.1 422 Unprocessable Entitycontent-type: text/plain; charset=utf-8content-length: 130
Failed to deserialize the JSON body into the target type: title: invalid type: integer `42`, expected a string at line 1 column 12HTTP/1.1 405 Method Not Allowedallow: GET,HEAD,DELETEcontent-length: 0Stop 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.
Common mistakes
Section titled “Common mistakes”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 messageWhen 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.
Ideas to extend it
Section titled “Ideas to extend it”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 withiter_mut(), setdone = true, and return it. - Validation. An empty title shouldn’t be allowed. A handler can return
Result<(StatusCode, Json<Todo>), (StatusCode, String)>, and send backErr((StatusCode::BAD_REQUEST, String::from("title must not be empty"))). Usetitle.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>andnext_id: u32inside theMutex. - Persistence with a JSON file. Derive
DeserializeonTodotoo. At startup, readtodos.jsonwithstd::fs::read_to_stringandserde_json::from_str(or start empty if the file doesn’t exist). After every change, write the list back withserde_json::to_string_prettyandstd::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 traceandcargo 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.1only accepts requests from your own computer. To accept requests from other machines (for example inside Docker or on a server), bind to0.0.0.0instead. Many hosting services tell your program which port to use through aPORTenvironment variable, which you can read withstd::env::var(see Environment Variables and Processes). Build withcargo build --releasefor a much faster server. The result is a single file intarget/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::serveruns it on atokioTcpListener. - Extractors pull data from the request:
Pathfor parts of the URL (/todos/{id}),Jsonfor the body (always last), andStatefor shared data. - Return
Json(value)for JSON,StatusCodefor a status, a tuple to set both, or aResultto 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:-ishows the status,-Xsets the method,-Hadds a header, and-dsends a body.
Quick check
Section titled “Quick check”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
Read more
Section titled “Read more”- axum documentation — docs.rs — the official guide, with sections on routing, handlers, and responses.
- axum extractors — docs.rs — every built-in extractor and the rules for their order.
- Tokio tutorial — learn the async runtime that powers axum, including shared state.
- tower-http documentation — docs.rs — ready-made layers for logging, CORS, compression, and timeouts.
- HTTP response status codes — MDN — what 200, 201, 204, 404, 422 and the others mean.