iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Bootcamp

A 60-minute Rust bootcamp that ships a working tokio + axum web service with typed JSON, tests, and a Dockerfile. The smallest reproducible loop.

A 60-minute Rust bootcamp

EXAMPLE
# ===== Objectives =====
# 1. Scaffold a cargo project
# 2. Build a typed HTTP service with axum
# 3. JSON I/O with serde
# 4. Tests + clippy
# 5. Dockerfile + run

# ===== 0-5 min: scaffold =====
cargo new shop-api && cd shop-api

# Cargo.toml
# [package]
# name = "shop-api"
# version = "0.1.0"
# edition = "2021"
#
# [dependencies]
# axum = "0.7"
# tokio = { version = "1", features = ["full"] }
# serde = { version = "1", features = ["derive"] }
# serde_json = "1"
# tower = "0.4"
# tracing = "0.1"
# tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# tokio-util = "0.7"

# ===== 5-25 min: the service =====
# src/main.rs
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::RwLock;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Order {
    id: String,
    customer: String,
    total_cents: i64,
    #[serde(default = "default_status")]
    status: String,
}

fn default_status() -> String { "new".to_string() }

#[derive(Clone)]
struct AppState {
    orders: Arc<RwLock<HashMap<String, Order>>>,
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();

    let state = AppState { orders: Arc::new(RwLock::new(HashMap::new())) };

    let app = Router::new()
        .route("/healthz", get(|| async { "ok" }))
        .route("/orders",     post(create_order))
        .route("/orders/:id", get(get_order))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    tracing::info!("listening on {}", listener.local_addr().unwrap());
    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await
        .unwrap();
}

async fn create_order(State(s): State<AppState>, Json(o): Json<Order>) -> impl IntoResponse {
    if o.id.is_empty() || o.total_cents < 0 {
        return (StatusCode::BAD_REQUEST, "validation").into_response();
    }
    s.orders.write().await.insert(o.id.clone(), o.clone());
    (StatusCode::CREATED, Json(o)).into_response()
}

async fn get_order(State(s): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
    match s.orders.read().await.get(&id).cloned() {
        Some(o) => Json(o).into_response(),
        None    => StatusCode::NOT_FOUND.into_response(),
    }
}

async fn shutdown_signal() {
    let ctrl_c = async {
        tokio::signal::ctrl_c().await.expect("install Ctrl+C handler");
    };
    #[cfg(unix)]
    let term = async {
        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
            .expect("install signal handler").recv().await;
    };
    #[cfg(not(unix))]
    let term = std::future::pending::<()>();
    tokio::select! { _ = ctrl_c => {}, _ = term => {} }
    tracing::info!("shutting down");
}

# Run
# cargo run
# curl -X POST http://localhost:8080/orders -d '{"id":"o1","customer":"alice","total_cents":4995}' -H "content-type: application/json"
# curl http://localhost:8080/orders/o1

# ===== 25-40 min: tests =====
# tests/integration.rs
use axum::{body::Body, http::{Request, StatusCode}};
use serde_json::json;
use tower::ServiceExt;
use shop_api::*; // assuming you exposed the router via lib.rs

#[tokio::test]
async fn create_and_get_order() {
    let app = router(); // factory you expose for tests

    let create = Request::builder()
        .method("POST").uri("/orders")
        .header("content-type", "application/json")
        .body(Body::from(json!({ "id": "o1", "customer": "alice", "total_cents": 4995 }).to_string()))
        .unwrap();
    let res = app.clone().oneshot(create).await.unwrap();
    assert_eq!(res.status(), StatusCode::CREATED);

    let get = Request::builder().uri("/orders/o1").body(Body::empty()).unwrap();
    let res = app.oneshot(get).await.unwrap();
    assert_eq!(res.status(), StatusCode::OK);
}

# Refactor main.rs to expose a `router()` factory in lib.rs for testing.

# Run
# cargo test
# cargo clippy -- -D warnings

# ===== 40-55 min: Dockerfile (multi-stage) =====
# Dockerfile
# FROM rust:1.78-alpine AS build
# WORKDIR /src
# RUN apk add --no-cache musl-dev
# COPY Cargo.toml Cargo.lock ./
# COPY src ./src
# RUN cargo build --release
#
# FROM gcr.io/distroless/cc-debian12:nonroot
# COPY --from=build /src/target/release/shop-api /app
# EXPOSE 8080
# ENTRYPOINT ["/app"]
#
# docker build -t shop-api:1.0 .
# docker run --rm -p 8080:8080 shop-api:1.0

# ===== 55-60 min: deploy =====
# - Fly.io: fly launch + fly deploy
# - Cloud Run: gcloud run deploy
# - Lambda (cold start matters): cargo lambda

# ===== Post-bootcamp checklist =====
# - Typed JSON via serde
# - State shared via Arc<RwLock<>>
# - Graceful shutdown via signal channel
# - Tests pass; clippy is silent
# - Distroless static-ish binary

# ===== Pitfalls =====
# - .unwrap() in handlers -> panics become 500s without info
# - Holding a RwLock guard across await -> deadlock
# - Big JSON without a body size limit (axum has DefaultBodyLimit)
# - cargo build without --release for the prod image

Why it matters

A working axum service with tests, shutdown, and a distroless image is the Rust "hello world" that actually proves you can ship. Build the bootcamp once and the same shape scales from a tiny CLI to a fleet of services — Rust shines when the patterns are baked in from minute one.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
// 30-day Rust bootcamp in the lesson body.
Try it Yourself »

Discussion

Loading…