Step 1 — Create the project
cargo new axum-demo
cd axum-demo
Add dependencies to Cargo.toml:
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Step 2 — The full main.rs
use axum::{
extract::State,
http::StatusCode,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
// ── 1. Data model ──────────────────────────────────────────────────────────────
// In FastAPI you'd write:
// class User(BaseModel):
// id: int
// name: str
// email: str
//
// In Rust we derive Serialize (→ JSON output) and Deserialize (← JSON input)
#[derive(Debug, Serialize, Deserialize, Clone)]
struct User {
id: u32,
name: String,
email: String,
}
// Only these fields are required when CREATING a user (no id yet)
// FastAPI equivalent: class CreateUser(BaseModel): name: str; email: str
#[derive(Debug, Deserialize)]
struct CreateUser {
name: String,
email: String,
}
// ── 2. Shared in-memory state ──────────────────────────────────────────────────
// Arc = shared ownership across threads (like Python's threading.Lock wrapper)
// Mutex= only one thread can write at a time
// Vec = growable list (like Python's list)
type AppState = Arc<Mutex<Vec<User>>>;
// ── 3. Handlers ───────────────────────────────────────────────────────────────
// POST /users
// FastAPI equivalent:
// @app.post("/users", status_code=201)
// def create_user(body: CreateUser):
// ...
async fn create_user(
State(state): State<AppState>, // Axum injects shared state automatically
Json(body): Json<CreateUser>, // Axum parses the JSON body automatically
) -> (StatusCode, Json<User>) {
let mut users = state.lock().unwrap(); // lock the Mutex to get write access
let new_user = User {
id: users.len() as u32 + 1, // simple auto-increment
name: body.name,
email: body.email,
};
users.push(new_user.clone());
(StatusCode::CREATED, Json(new_user)) // returns 201 + the created user as JSON
}
// GET /users
// FastAPI equivalent:
// @app.get("/users")
// def list_users():
// ...
async fn list_users(
State(state): State<AppState>,
) -> Json<Vec<User>> {
let users = state.lock().unwrap(); // lock for read access
Json(users.clone()) // return a copy as JSON
}
// ── 4. main — wire everything together ────────────────────────────────────────
#[tokio::main] // like: if __name__ == "__main__": uvicorn.run(app)
async fn main() {
let state: AppState = Arc::new(Mutex::new(vec![])); // start with empty list
let app = Router::new()
.route("/users", post(create_user)) // POST /users → create_user handler
.route("/users", get(list_users)) // GET /users → list_users handler
.with_state(state); // attach shared state to all routes
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Server running on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
Step 3 — Run and test
cargo run
Create a user:
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "email": "alice@example.com"}'
# Response: {"id":1,"name":"Alice","email":"alice@example.com"}
List all users:
curl http://localhost:3000/users
# Response: [{"id":1,"name":"Alice","email":"alice@example.com"}]
Key concepts to remember
🔹 Debug
- Lets you print the struct in a developer‑friendly way using
println!("{:?}", user). - Without it, you can’t easily inspect values during debugging.
🔹 Serialize
- Comes from Serde.
- Allows you to convert your struct into JSON (or other formats).
- Example: turn
User { id: 1, name: "Alice".to_string(), email: "a@b.com".to_string() }into:
{
"id": 1,
"name": "Alice",
"email": "a@b.com"
}
🔹 Deserialize
- Also from Serde.
- Allows you to take JSON (or other formats) and build your struct back.
- Example: given the JSON above, Serde can create a
Userstruct automatically.
🔹 Clone
- Lets you make a copy of the struct.
- Example:
let user1 = User { id: 1, name: "Alice".to_string(), email: "a@b.com".to_string() };
let user2 = user1.clone(); // now user2 is a copy of user1
Without Clone, you couldn’t duplicate the struct this way.
Why all together?
-
Debug→ for printing and debugging. -
Serialize+Deserialize→ for JSON in/out (essential in web APIs). -
Clone→ for copying values when needed.
So this combination makes your User struct easy to debug, send/receive as JSON, and duplicate safely.
Now lets understand Arc<Mutex<Vec<User>>> & State(...)
Arc<Mutex<Vec<User>>> — The shared notebook
Imagine you run a small shop. You have one notebook where you write down customer names.
Now imagine multiple cashiers (= multiple request handlers running at the same time). They all need to read and write that same notebook.
Two problems arise:
Problem 1 — Who owns the notebook?
In Rust, every value has exactly one owner. But you need all cashiers to share it. Arc solves this — think of it as a photocopy machine that gives everyone a pointer to the same original notebook. Nobody owns it exclusively; everyone just holds a reference.
Arc= shared ownership across threads. When the last cashier is done, it auto-deletes.
Problem 2 — Two cashiers writing at the same time = chaos
If cashier A and cashier B both write at the exact same millisecond, the notebook gets corrupted. Mutex solves this — think of it as a physical lock on the notebook cabinet. Only one cashier can open it at a time. Others wait.
Mutex= take a lock, do your work, release the lock
So the full picture:
Arc < Mutex < Vec<User> >>
"shared" "one at "the actual
pointer" a time" notebook"
In Python you almost never think about this because Python's GIL handles it behind the scenes. In Rust, you are explicit about it — which makes it safer.
State(...) — The magic backpack
When your server starts, you create the notebook (the Vec) and put it in a backpack:
let state = Arc::new(Mutex::new(vec![]));
let app = Router::new()
.route("/users", post(create_user))
.with_state(state); // 👈 put it in the backpack
Now every incoming request automatically carries that backpack. When a handler wants the notebook, it just asks for it:
async fn create_user(
State(state): State<AppState>, // 👈 "give me what's in the backpack"
Json(body): Json<CreateUser>,
) { ... }
Axum sees State(AppState) in the function signature and says "oh, this handler needs the shared state — let me inject it automatically."
FastAPI comparison:
# FastAPI — you use Depends() to inject shared things
def get_db():
return fake_db
@app.post("/users")
def create_user(body: CreateUser, db=Depends(get_db)):
# ^^^^^^^^^^^^^^^^
# FastAPI injects this
// Axum — you use State(...) to inject shared things
async fn create_user(
State(state): State<AppState>, // Axum injects this
Json(body): Json<CreateUser>,
) { ... }
Same idea — the framework injects a shared dependency into your function. The syntax is just different.
One more thing — why does Rust force you to think about this?
In Python, you write:
users = [] # global list, everyone just uses it
@app.post("/users")
def create_user(body: CreateUser):
users.append(...) # just works, Python handles safety behind the scenes
Python hides the complexity. Rust makes you say it out loud:
// "This is a list, shared across threads, protected by a lock"
let state: Arc<Mutex<Vec<User>>> = Arc::new(Mutex::new(vec![]));
It feels verbose at first, but this is exactly why Rust programs don't have race conditions — you cannot accidentally share data unsafely. The compiler won't let you.
So we can say, Arc<Mutex<Vec<User>>> is the "database" for now. Arc lets multiple request handlers share the same data. Mutex ensures only one handler writes at a time — without it, two simultaneous POST requests could corrupt the list.
State(...) — Axum automatically injects the shared state into any handler that asks for it. In FastAPI you'd use Depends() for something similar.
Json(...) does double duty — as an extractor in function parameters (parses incoming JSON) and as a response type in the return value (serializes to JSON output).
(StatusCode, Json<T>) as a return type is how you return both a status code and a body. Returning just Json<T> implicitly gives a 200 OK.
Thank you for reading ^_^

Top comments (0)