Step 21 of 21
Build a containerized RESTful API — Axum, sqlx, garde, utoipa, and Docker Compose in one spec-driven workshop
Workshop: สร้าง production-style RESTful API ด้วย Axum + sqlx + garde + utoipa จบด้วย Docker — ใช้ทุกอย่างที่เรียนมา
Scenario: A small bookshop wants to sell online. Their web team needs a catalog API. It must run the same on every machine — so it ships in Docker.
Build a production-style RESTful API in Rust. One service, one table, the full pipeline: server, config, database, validation, docs, containers.
You write all the code. This file tells you what to build and how each part is judged.
Level: Mid — finish modules 00–05 first. Time: 1–2 weeks.
| Method | Route | Action |
|---|---|---|
| GET | /v1/books | List books (paged) |
| POST | /v1/books | Add a book |
| GET | /v1/books/{id} | Get one book |
| PUT | /v1/books/{id} | Update a book |
| DELETE | /v1/books/{id} | Remove a book |
| GET | /livez | Health check |
| Tool | Job |
|---|---|
| axum + tokio | HTTP server and async runtime |
| tower + tower-http | CORS, request ID, trace middleware |
| serde + serde_json | JSON in and out |
| sqlx + PostgreSQL | Database with compile-time checked SQL |
| garde | Input validation |
| utoipa | OpenAPI docs generated from code |
| tracing | Structured JSON logs |
| envconfig | Config from environment variables |
| thiserror + anyhow | Domain errors and app errors |
| Docker Compose | App + database in containers |
One table: books.
| Column | Type | Null | Notes |
|---|---|---|---|
created_at | TIMESTAMPTZ | no | set on insert |
updated_at | TIMESTAMPTZ | no | set on insert and update |
id | UUID | no | primary key, UUIDv7 made in app code |
published_date | DATE | no | |
status | SMALLINT | no | 0 = draft, 1 = published |
title | TEXT | no | |
description | TEXT | yes | |
image_url | TEXT | yes |
Tip — column order matters. Put fixed-width columns first, widest to smallest. Put text columns last. This trims row padding in PostgreSQL. Known as "Column Tetris". Nice to know, not graded.
Request body — POST and PUT:
{
"title": "Harry Potter and the Deathly Hallows",
"description": "The seventh and final novel in the series",
"image_url": "https://example.com/cover.jpg",
"published_date": "2007-07-21",
"status": "published"
}
Response body — GET, POST, PUT:
{
"id": "018f6b2e-7c1a-7b3e-9f2a-3d4c5b6a7f81",
"title": "Harry Potter and the Deathly Hallows",
"description": "The seventh and final novel in the series",
"image_url": "https://example.com/cover.jpg",
"published_date": "2007-07-21",
"status": "published",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
The list endpoint returns an array of the response above.
Validation error — status 422:
{
"errors": {
"title": "Must be at least 1 character long",
"image_url": "Must be a valid URL"
}
}
Other errors — status 500, plain and safe:
{ "error": "DB_FETCH_FAILED" }
Seven parts. Do them in order. Each part ends with a checkpoint — do not move on until it passes.
book_serviceapp (the server) and migration (runs migrations), plus a lib target for shared coderustfmt.toml and clippy rules set from day onejustfile (or Makefile) with recipes: run, test, lint, migrateCheckpoint
cargo clippy --workspace --all-targets -- -D warnings passesjust run starts the server and prints a startup logGET /livez returns 200 OKenvconfig (or similar) — the app panics fast with a clear message if config is missingtracing; control log level with one env var (e.g. RUST_LOG)Checkpoint
curl -i localhost:3000/livez returns 200Add tower-http layers to the router:
x-request-id header in the responseCheckpoint
curl calls return different x-request-id valuescompose.yml runs PostgreSQL with a health checkbooks table created by a sqlx migration — never by hand#[derive(sqlx::Type)]:
Draft = 0, Published = 1uuid cratesqlx::query_as! or query! — compile-time checkedAppStateCheckpoint
docker compose up -d db then just migrate creates the table?page=1&per_page=10 — per_page clamped to 100 maxIntoResponse:| Failure | Status | Body |
|---|---|---|
| Malformed JSON | 400 | axum rejection |
| Book not found | 404 | empty |
| Invalid input | 422 | field errors |
| DB insert fails | 500 | {"error": "DB_INSERT_FAILED"} |
| DB fetch fails | 500 | {"error": "DB_FETCH_FAILED"} |
| DB update fails | 500 | {"error": "DB_UPDATE_FAILED"} |
| DB delete fails | 500 | {"error": "DB_DELETE_FAILED"} |
tracing, return a short safe message. Never leak internal details to clients.Checkpoint
curl: create, list, read, update, deleteGET /v1/books/{random-uuid} returns 404garde with rules on the request struct:
title: 1–255 charactersimage_url: must be a valid URL, when presentValidatedJson<T> extractor (implement FromRequest) so handlers never see bad input422 with one message per fieldCheckpoint
title returns 422 with "Must be at least 1 character long"image_url: "not-a-url" returns 422#[utoipa::path], derive ToSchema on typesopenapi.yaml from the code — a small apidoc binary or a test that writes the filedocker compose up --build
Checkpoint
openapi.yaml lists all five book endpoints with request and response schemasdocker compose up --build, then CRUD works on localhost:3000| Rule | Why |
|---|---|
| Rust 2024 edition | Repo standard |
No unwrap() or expect() outside tests | Panics take the whole server down |
| No SQL strings built by hand | Compile-time checked queries only |
| No config values in code | Twelve-factor: config comes from the environment |
| No secrets in git | Ship a .env.example, ignore .env |
| Errors log full detail, return safe messages | Clients see less, you see more |
cargo clippy --workspace --all-targets -- -D warnings passescargo test passes with tests for the error mapping and pagination clampssqlx query metadata committed (offline builds work — cargo sqlx prepare)docker compose up --build starts app + database from clean state/docs with utoipa-swagger-uilto, codegen-units = 1, stripmimallocisbn column with a migration — reject duplicates with 409400 malformed-body errors instead of plain text?title=harry filters the list endpointYou now have a small but honest production API. Natural next steps:
/livezThe deeper workshop in this repo — Content Moderation Pipeline — picks up where this one ends: concurrency, channels, and benchmarking.
Previous
Deploying Rust
Final step