DEV Community

Syeed Talha
Syeed Talha

Posted on

Implementing POST and GET in Axum using in-memory storage.

Step 1 — Create the project

cargo new axum-demo
cd axum-demo
Enter fullscreen mode Exit fullscreen mode

Add dependencies to Cargo.toml:

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Step 3 — Run and test

cargo run
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

List all users:

curl http://localhost:3000/users

# Response: [{"id":1,"name":"Alice","email":"alice@example.com"}]
Enter fullscreen mode Exit fullscreen mode

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"
  }
Enter fullscreen mode Exit fullscreen mode

🔹 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 User struct 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>,
) { ... }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// Axum — you use State(...) to inject shared things
async fn create_user(
    State(state): State<AppState>,  // Axum injects this
    Json(body): Json<CreateUser>,
) { ... }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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![]));
Enter fullscreen mode Exit fullscreen mode

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)