1 Rust+Axum Assignments
teacher edited this page 2026-04-13 13:46:17 +02:00

Rust + Axum Assignments — Complete Tutorial Guide

This guide walks through all ten assignments in the rust-assignments/ directory. Rust is a compiled, systems-level programming language that provides memory safety without a garbage collector. Axum is a web framework for Rust built on Tokio (an asynchronous runtime) and Tower (a library of network service abstractions).

These assignments assume basic familiarity with the Rust language — variable binding, functions, structs, enums, and Option/Result. If Rust is entirely new to you, work through the first few chapters of The Rust Programming Language first.


Prerequisites

Rust Installation

Install the Rust toolchain via rustup:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

This installs rustc (the compiler), cargo (the package manager and build tool), and rustup (the toolchain manager). Verify:

rustc --version
cargo --version

Cargo

cargo handles all project operations:

  • cargo build — compile the project
  • cargo run — compile and run
  • cargo check — check for errors without producing a binary (much faster than cargo build)
  • cargo add <crate> — add a dependency to Cargo.toml

Cargo.toml

Each assignment has a Cargo.toml file that lists dependencies (called crates). Running cargo run downloads and compiles all dependencies automatically — no separate install step required.

First Compilation

The first cargo run compiles all dependencies. This can take several minutes. Subsequent runs are much faster because only changed code is recompiled.

Default Port

All Rust assignments listen on http://127.0.0.1:3000.


Assignment 01: Basic To-Do API

Location: rust-assignments/01-basic-todo-api/

What You Will Learn

  • Defining an Axum Router with typed handlers
  • Shared mutable state with Arc<Mutex<T>>
  • The State extractor
  • Request and response types with serde
  • Path parameters with the Path extractor

Key Concepts

Axum extractors are types that Axum constructs by reading the incoming request. You declare them as parameters to handler functions:

async fn get_todo(
    State(state): State<AppState>,  // shared application state
    Path(id): Path<u64>,            // URL path parameter :id
    Json(body): Json<TodoCreate>,   // request body parsed as JSON
) -> impl IntoResponse {

If an extractor fails (e.g., the path parameter cannot be parsed as u64, or the JSON body is malformed), Axum automatically returns an appropriate error response without calling your handler.

serde is the Rust serialisation/deserialisation framework. Adding #[derive(Serialize, Deserialize)] to a struct allows Axum to automatically parse JSON into the struct and serialise the struct to JSON.

#[derive(Deserialize)]
struct TodoCreate {
    title: String,
    description: Option<String>,
}

#[derive(Serialize)]
struct Todo {
    id: u64,
    title: String,
    description: Option<String>,
    completed: bool,
}

Shared state and Arc<Mutex<>>: In a concurrent HTTP server, multiple requests may arrive simultaneously and all handlers run concurrently. If handlers need to read and write shared data (the todos list), access must be synchronised.

  • Arc<T> (Atomic Reference Counting) allows a value to be owned by multiple parts of the program simultaneously. Cloning an Arc increments a counter; dropping decrements it. The underlying data is freed when the count reaches zero.
  • Mutex<T> (Mutual Exclusion) ensures only one thread can access the inner value at a time. To read or write, you call .lock(), which returns a guard that releases the lock when dropped.
#[derive(Clone)]
struct AppState {
    todos: Arc<Mutex<Vec<Todo>>>,
}

// In a handler:
let mut todos = state.todos.lock().unwrap();
todos.push(new_todo);

Running the Server

cargo run

Project Files

src/main.rs

Contains all route definitions and handler functions. The router is built with method-based routing:

let app = Router::new()
    .route("/todos", get(list_todos).post(create_todo))
    .route("/todos/:id", get(get_todo).put(update_todo).delete(delete_todo))
    .with_state(state);

axum::serve(listener, app).await starts the server.

Testing Step by Step

# Create
curl -X POST http://127.0.0.1:3000/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Buy groceries"}'

# List
curl http://127.0.0.1:3000/todos

# Update (replace with the returned id)
curl -X PUT http://127.0.0.1:3000/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Buy groceries and milk", "completed": true}'

# Delete
curl -X DELETE http://127.0.0.1:3000/todos/1

Common Compilation Errors

  • E0507: cannot move out of ... which is behind a shared reference — Rust's ownership rules prevent moving a value out of a reference. Use .clone() or restructure to avoid moving.
  • E0308: mismatched types — a function expects one type but receives another. Check the return type of your handler.

Assignment 02: Personal Blog

Location: rust-assignments/02-personal-blog/

What You Will Learn

  • URL path parameters with string types
  • Using HashMap as in-memory storage
  • Conflict detection (duplicate slugs)
  • Returning different status codes from handlers

Key Concepts

HashMap<K, V> stores key-value pairs with O(1) average lookup. For the blog, post slugs serve as keys:

HashMap<String, Post>

Looking up a post: posts.get(&slug) returns Option<&Post>. None means the slug does not exist.

StatusCode in Axum: To return a specific HTTP status code alongside a JSON body, use a tuple response:

return (StatusCode::CONFLICT, Json(json!({"error": "Slug already exists"}))).into_response();

Axum's IntoResponse trait allows returning plain Json(...), tuples (StatusCode, Json(...)), or Html(...).

Path<String> vs Path<u64>: Slugs are strings, not integers. Path(slug): Path<String> extracts the URL parameter as a String.

Running the Server

cargo run

Testing Step by Step

curl -X POST http://127.0.0.1:3000/posts \
  -H "Content-Type: application/json" \
  -d '{"title": "First Post", "slug": "first-post", "content_markdown": "Hello world"}'

curl http://127.0.0.1:3000/posts/first-post

# Duplicate slug — expect 409
curl -X POST http://127.0.0.1:3000/posts \
  -H "Content-Type: application/json" \
  -d '{"title": "Another Post", "slug": "first-post", "content_markdown": "..."}'

Assignment 03: User Profile & Uploads

Location: rust-assignments/03-user-profile-uploads/

What You Will Learn

  • The Multipart extractor for file uploads
  • Writing files with tokio::fs
  • Serving static files with tower-http's ServeDir
  • Streaming file data from the request

Key Concepts

Multipart is Axum's extractor for multipart/form-data. It provides an async stream of Field objects. Each field has a name() (the form field name) and data methods (bytes(), chunk()).

async fn upload_image(mut multipart: Multipart) -> impl IntoResponse {
    while let Some(field) = multipart.next_field().await.unwrap() {
        if field.name() == Some("file") {
            let data = field.bytes().await.unwrap();
            tokio::fs::write("static/uploads/image.jpg", &data).await.unwrap();
        }
    }
}

tokio::fs provides asynchronous file I/O. tokio::fs::write(path, data) writes bytes to a file without blocking the async runtime.

ServeDir from tower-http serves a local directory as static files:

use tower_http::services::ServeDir;

let app = Router::new()
    // ...routes...
    .nest_service("/static", ServeDir::new("static"));

Files in static/ become accessible at /static/filename.

Running the Server

cargo run

Testing Step by Step

# View profile
curl http://127.0.0.1:3000/profiles/john_doe

# Upload image
curl -X POST \
  -F "file=@./photo.jpg" \
  http://127.0.0.1:3000/profiles/john_doe/upload-image

# View uploaded image
curl http://127.0.0.1:3000/static/uploads/john_doe.jpg --output received.jpg

Assignment 04: Task Manager with Background Tasks

Location: rust-assignments/04-task-manager-bg-tasks/

What You Will Learn

  • Detached async tasks with tokio::spawn
  • How Tokio's async runtime differs from synchronous execution
  • The difference between concurrent and sequential execution

Key Concepts

Tokio is an asynchronous runtime for Rust. It provides:

  • A thread pool that executes async tasks
  • Async versions of I/O operations (file, network, timers)
  • tokio::spawn — spawns a new async task that runs concurrently

tokio::spawn creates a new task and hands it to the Tokio runtime. The calling function continues immediately without waiting for the spawned task to finish. This is Rust's equivalent of FastAPI's BackgroundTasks and JavaScript's fire-and-forget pattern:

tokio::spawn(async move {
    tokio::time::sleep(Duration::from_secs(5)).await;
    println!("Notification sent to {}", email);
});
// Code here runs immediately, before the 5-second sleep completes

The move keyword transfers ownership of captured variables into the async block. This is required because the spawned task may outlive the function that created it.

tokio::time::sleep is the async equivalent of thread::sleep. It suspends the current task without blocking the thread, allowing other tasks to run during the wait.

Running the Server

cargo run

Testing Step by Step

  1. Start the server with the terminal visible.
  2. Create a task: curl -X POST http://127.0.0.1:3000/tasks -H "Content-Type: application/json" -d '{"title": "Deploy", "user_email": "dev@example.com"}'
  3. Complete it: curl -X POST http://127.0.0.1:3000/tasks/1/complete
  4. The HTTP response arrives immediately. After 5 seconds, the terminal logs the notification.

Assignment 05: Weather Dashboard

Location: rust-assignments/05-weather-dashboard/

What You Will Learn

  • The Query extractor for query parameters
  • Making HTTP requests with reqwest
  • Storing reqwest::Client in shared state for connection reuse
  • Building an in-memory TTL cache in Rust

Key Concepts

Query<T> extractor: Axum deserialises query string parameters into a struct annotated with #[derive(Deserialize)]:

#[derive(Deserialize)]
struct WeatherQuery {
    city_name: Option<String>,
    lat: f64,
    lon: f64,
}

async fn get_weather(Query(params): Query<WeatherQuery>, ...) -> impl IntoResponse {

reqwest is an asynchronous HTTP client for Rust. Unlike Python's requests (synchronous), reqwest integrates with the Tokio async runtime:

let response = reqwest::Client::new()
    .get("https://api.open-meteo.com/v1/forecast")
    .query(&[("latitude", lat), ("longitude", lon)])
    .send()
    .await?
    .json::<serde_json::Value>()
    .await?;

Client reuse: Creating a new reqwest::Client for every request is wasteful — each client creates a new connection pool. The idiomatic approach is to create one client and store it in AppState:

struct AppState {
    http_client: reqwest::Client,
    cache: Arc<Mutex<HashMap<String, CacheEntry>>>,
}

Running the Server

cargo run

An internet connection is required.

Testing Step by Step

# First call
curl "http://127.0.0.1:3000/weather?city_name=London&lat=51.5&lon=-0.12"

# Second call (from cache)
curl "http://127.0.0.1:3000/weather?city_name=London&lat=51.5&lon=-0.12"

Assignment 06: Real-time Chat

Location: rust-assignments/06-real-time-chat/

What You Will Learn

  • The WebSocket upgrade flow in Axum
  • Tokio broadcast channels for fan-out messaging
  • tokio::select! for concurrent operations
  • Handling WebSocket send and receive simultaneously

Key Concepts

WebSocketUpgrade is an Axum extractor that handles the HTTP → WebSocket protocol upgrade. Your handler receives it, and you call .on_upgrade(handler) to transition to WebSocket mode:

async fn ws_handler(ws: WebSocketUpgrade, ...) -> impl IntoResponse {
    ws.on_upgrade(handle_socket)
}

async fn handle_socket(mut socket: WebSocket) {
    // socket.send(...), socket.recv(), ...
}

Tokio broadcast channel: A broadcast::Sender<String> allows any number of senders and receivers. Each new client creates a new Receiver by calling sender.subscribe(). When a message is sent via sender.send(msg), every active receiver gets a copy:

let (tx, _rx) = broadcast::channel::<String>(100);
// Store tx in AppState

// In a handler:
let mut rx = state.tx.subscribe();
tx.send("Hello everyone".to_string()).unwrap();

tokio::select! waits for multiple async operations concurrently and runs the branch that completes first:

loop {
    tokio::select! {
        // Message from this client
        Some(Ok(msg)) = socket.recv() => {
            state.tx.send(format!("Client: {}", msg.to_text().unwrap())).unwrap();
        }
        // Message from any other client (via broadcast)
        Ok(msg) = rx.recv() => {
            socket.send(Message::Text(msg)).await.unwrap();
        }
        else => break,  // connection closed
    }
}

This replaces the need for separate threads: a single async loop handles both sending and receiving.

Running the Server

cargo run

Testing Step by Step

  1. Start the server.
  2. Open http://127.0.0.1:3000/ in two browser tabs.
  3. Type in one tab and click "Send". Message appears in the other tab.
  4. Close one tab. The other shows a disconnection notice.

Assignment 07: Form Handling & Templates

Location: rust-assignments/07-forms-templates/

What You Will Learn

  • The Form extractor for HTML form data
  • Returning HTML responses with Html<T>
  • Inline HTML generation in Rust
  • The application/x-www-form-urlencoded format

Key Concepts

Form<T> extractor parses application/x-www-form-urlencoded request bodies into a typed struct. HTML forms without enctype="multipart/form-data" use this encoding by default:

#[derive(Deserialize)]
struct SubscriptionForm {
    name: String,
    email: String,
}

async fn subscribe(Form(data): Form<SubscriptionForm>) -> impl IntoResponse {
    // data.name, data.email
}

Html<T>: Axum's Html wrapper signals that the response body is HTML:

return Html("<h1>Success</h1>").into_response();
return Html(format!("<p>Hello, {}!</p>", name)).into_response();

Returning with status code:

return (StatusCode::UNPROCESSABLE_ENTITY, Html(error_page)).into_response();

Running the Server

cargo run

Testing Step by Step

  1. Open http://127.0.0.1:3000/.
  2. Submit a valid form. Observe the success page.
  3. Submit with a single-character name. Observe the error.
  4. GET http://127.0.0.1:3000/admin/subscriptions — JSON list of all submissions.

Assignment 08: Webhook Receiver

Location: rust-assignments/08-webhook-receiver/

What You Will Learn

  • The Bytes extractor for raw request body access
  • Header extraction with TypedHeader or HeaderMap
  • HMAC verification with the hmac and sha2 crates
  • Constant-time comparison

Key Concepts

See Assignment 08 in the FastAPI guide for the conceptual explanation of webhooks, HMAC, and timing attacks.

Bytes extractor: To access the raw request body without any parsing:

async fn receive_webhook(
    headers: HeaderMap,
    body: Bytes,
) -> impl IntoResponse {
    let raw_body: &[u8] = &body;
    // compute HMAC over raw_body
}

HMAC in Rust uses the hmac and sha2 crates:

use hmac::{Hmac, Mac};
use sha2::Sha256;

type HmacSha256 = Hmac<Sha256>;

fn verify_signature(payload: &[u8], secret: &[u8], signature: &str) -> bool {
    let mut mac = HmacSha256::new_from_slice(secret).unwrap();
    mac.update(payload);
    let expected = hex::encode(mac.finalize().into_bytes());
    // Constant-time comparison
    expected.as_bytes().ct_eq(signature.as_bytes()).into()
}

The subtle crate provides ConstantTimeEq (.ct_eq()), which performs constant-time byte comparison.

Header extraction:

let signature = headers
    .get("x-webhook-signature")
    .and_then(|v| v.to_str().ok())
    .unwrap_or("");

Running the Server

cargo run

Testing Step by Step

# Use the Python test script from the FastAPI webhook assignment (it targets port 5000 originally).
# Change the URL to http://127.0.0.1:3000/webhook and adjust port accordingly.

# View logged events
curl http://127.0.0.1:3000/admin/events

Assignment 09: Inventory Management

Location: rust-assignments/09-inventory-management/

What You Will Learn

  • Nesting routers in Axum for API versioning
  • Query parameter structs for pagination
  • Filtering and slicing vectors

Key Concepts

Router::nest("/prefix", sub_router) mounts a sub-router at a URL prefix. Routes defined in sub_router are accessible at /prefix/route:

let v1_routes = Router::new()
    .route("/items", get(list_items_v1));

let v2_routes = Router::new()
    .route("/items", get(list_items_v2));

let app = Router::new()
    .nest("/v1", v1_routes)
    .nest("/v2", v2_routes)
    .with_state(state);

Pagination query struct:

#[derive(Deserialize)]
struct PaginationParams {
    offset: Option<usize>,
    limit: Option<usize>,
    category: Option<String>,
}

Vector slicing in Rust:

let offset = params.offset.unwrap_or(0);
let limit = params.limit.unwrap_or(20);
let page: Vec<&Item> = items.iter()
    .skip(offset)
    .take(limit)
    .collect();

Running the Server

cargo run

Testing Step by Step

curl "http://127.0.0.1:3000/v1/items?offset=0&limit=5"
curl "http://127.0.0.1:3000/v2/items?category=Electronics&offset=0&limit=10"

Assignment 10: Secure Document Vault

Location: rust-assignments/10-secure-document-vault/

What You Will Learn

  • JWT authentication in Rust with the jsonwebtoken crate
  • Password hashing with argon2
  • Axum middleware using from_fn_with_state
  • Passing claims through request extensions

Key Concepts

See Assignment 10 in the FastAPI guide for conceptual background.

Argon2 is a memory-hard password hashing algorithm — it requires a configurable amount of memory to compute, making parallel brute-force attacks expensive. It won the 2015 Password Hashing Competition and is recommended for new applications:

use argon2::{Argon2, PasswordHash, PasswordVerifier, password_hash::SaltString};
use argon2::PasswordHasher;

// Hash
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?.to_string();

// Verify
let parsed_hash = PasswordHash::new(&stored_hash)?;
Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok()

jsonwebtoken crate for JWT:

use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey};

#[derive(Serialize, Deserialize)]
struct Claims {
    sub: String,
    scopes: Vec<String>,
    exp: usize,  // expiry timestamp
}

// Encode
let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(SECRET))?;

// Decode
let data = decode::<Claims>(&token, &DecodingKey::from_secret(SECRET), &Validation::default())?;
let claims = data.claims;

Axum middleware with from_fn_with_state: Middleware is a function that wraps a handler. It can access shared state and modify or reject requests before they reach the handler:

async fn auth_middleware(
    State(state): State<AppState>,
    mut req: Request,
    next: Next,
) -> impl IntoResponse {
    // Extract and verify Bearer token
    // Decode JWT, get Claims
    // req.extensions_mut().insert(claims)  — attach to request
    next.run(req).await
}

Handlers retrieve the claims via Extension<Claims>:

async fn get_documents(
    Extension(claims): Extension<Claims>,
    State(state): State<AppState>,
) -> impl IntoResponse {
    // claims.sub, claims.scopes
}

Running the Server

cargo run

Testing Step by Step

# Authenticate
curl -X POST http://127.0.0.1:3000/token \
  -H "Content-Type: application/json" \
  -d '{"username": "alice", "password": "alice123"}'

TOKEN="<paste_token>"

# Read documents
curl http://127.0.0.1:3000/documents \
  -H "Authorization: Bearer $TOKEN"

# Create document above clearance level
curl -X POST http://127.0.0.1:3000/documents \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title": "Top Secret", "content": "...", "secret_level": 4}'
# Expect 403

# Admin endpoint (alice lacks admin scope)
curl http://127.0.0.1:3000/admin/users \
  -H "Authorization: Bearer $TOKEN"
# Expect 403

Authenticate as admin / admin123 and repeat the admin request to verify full access.