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.
Discussion
Loading…