Introduction
You’ve finished the Rust Essentials course — now let’s build something real. In this post we’ll create a REST API using Axum, the most popular async Rust web framework. Think of it as the Rust equivalent of our CrowCpp tutorial.
Axum runs on Tokio (async runtime) and Tower (middleware). It’s fast, ergonomic, and production-ready.
Project Setup
Create a new project:
cargo new axum-api
cd axum-api
Add to Cargo.toml:
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Step 1 — Hello World
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(hello));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listening on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
async fn hello() -> &'static str {
"Hello from Axum!"
}
Run with cargo run and open http://localhost:3000.
Step 2 — JSON Responses
Derive Serialize on a struct and wrap it in Axum’s Json extractor:
use axum::{routing::get, Json, Router};
use serde::Serialize;
#[derive(Serialize)]
struct User {
id: u32,
name: String,
}
async fn get_user() -> Json<User> {
Json(User {
id: 1,
name: "Alice".to_string(),
})
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/user", get(get_user));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
GET /user returns {"id":1,"name":"Alice"}.
Step 3 — Path Parameters
Extract values from the URL using Path:
use axum::{extract::Path, routing::get, Json, Router};
use serde::Serialize;
#[derive(Serialize)]
struct User {
id: u32,
name: String,
}
async fn get_user(Path(id): Path<u32>) -> Json<User> {
Json(User { id, name: format!("User {}", id) })
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/user/:id", get(get_user));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
GET /user/42 returns {"id":42,"name":"User 42"}.
Step 4 — POST with JSON Body
Use Json as a function parameter to extract and deserialize the request body:
use axum::{routing::post, Json, Router};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct CreateUser {
name: String,
}
#[derive(Serialize)]
struct User {
id: u32,
name: String,
}
async fn create_user(Json(payload): Json<CreateUser>) -> Json<User> {
Json(User { id: 99, name: payload.name })
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/user", post(create_user));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Test with curl:
curl -X POST http://localhost:3000/user \
-H "Content-Type: application/json" \
-d '{"name": "Bob"}'
Step 5 — Status Codes
Return a tuple to control the HTTP status code:
use axum::{http::StatusCode, Json};
use serde::Serialize;
#[derive(Serialize)]
struct ErrorBody {
message: String,
}
async fn not_found() -> (StatusCode, Json<ErrorBody>) {
(
StatusCode::NOT_FOUND,
Json(ErrorBody { message: "Not found".to_string() }),
)
}
Complete Example
use axum::{
extract::Path,
http::StatusCode,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Clone)]
struct User {
id: u32,
name: String,
}
#[derive(Deserialize)]
struct CreateUser {
name: String,
}
async fn list_users() -> Json<Vec<User>> {
Json(vec![
User { id: 1, name: "Alice".to_string() },
User { id: 2, name: "Bob".to_string() },
])
}
async fn get_user(Path(id): Path<u32>) -> (StatusCode, Json<User>) {
(StatusCode::OK, Json(User { id, name: format!("User {}", id) }))
}
async fn create_user(Json(payload): Json<CreateUser>) -> (StatusCode, Json<User>) {
(StatusCode::CREATED, Json(User { id: 99, name: payload.name }))
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users", get(list_users).post(create_user))
.route("/users/:id", get(get_user));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Server running at http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
Exercises
- Add a
DELETE /users/:idroute that returns204 No Content. - Add a
namequery parameter toGET /usersto filter by name. Useaxum::extract::Query. - Add a
GET /healthendpoint returning{"status":"ok"}.
What’s Next?
- State — share database connections across handlers via
State<T> - Middleware — logging, CORS, and auth via Tower layers
- Database — pair with
sqlxfor SQLite or Postgres