Project: Orders API with Axum & Tests
In this project you will build orders-api, the backend for a small online stationery shop in Pune. Shop staff and the mobile app use it to place orders, look them up and move them through packing and delivery. It is small enough to finish in an afternoon, yet it is organised the way a team would organise a real service: separate modules for the model, storage, errors and HTTP handlers; business rules with unit tests; and integration tests that call the API without starting a server.
You will use what you learned in Web APIs with Axum, Serde & JSON in Depth, Error Handling with ? & Custom Errors and Testing: Unit, Integration & Doc Tests. Run cargo check after every step.
Step 1: plan the API
| Method and path | What it does | Success |
|---|---|---|
POST /orders | Place a new order from JSON | 201 Created with the order |
GET /orders | List orders, optionally ?status=packed | 200 OK with a list |
GET /orders/{id} | Fetch one order | 200 OK, or 404 |
PATCH /orders/{id}/status | Move an order to a new status | 200 OK, or 409 Conflict if not allowed |
GET /health | Health check for the load balancer | 200 OK with ok |
Business rules:
- An order needs a customer name and at least one item, and every quantity must be at least 1. Breaking a rule returns
422 Unprocessable Entity. - Status moves only forward: placed, packed, shipped, delivered. An order can be cancelled only while it is placed or packed.
- Money is stored in paise as whole numbers (Rs. 45.00 is
4500). Floating-point numbers cannot store most decimal amounts exactly, and rounding errors in bills are not acceptable. - Every error response is JSON of the form
{"error": "message"}.
Step 2: create the project
cargo new orders-api
cd orders-api
cargo add axum serde_json thiserror tracing tracing-subscriber
cargo add serde --features derive
cargo add tokio --features full
cargo add tower-http --features trace
cargo add --dev http-body-util
cargo add --dev tower --features utilThe two --dev crates are only for tests: tower provides oneshot, which sends one request to the router, and http-body-util reads the response body. The code goes into six files:
src/main.rs starts the server (thin)
src/lib.rs builds the router: app()
src/model.rs orders, items, statuses and rules
src/error.rs AppError and its HTTP responses
src/store.rs in-memory storage
src/routes.rs HTTP handlers
tests/api.rs integration testsKeeping almost everything in the library (lib.rs and its modules) is what makes integration tests possible: a test file can call orders_api::app() the same way main does.
Step 3: the model and its rules
Create src/model.rs. The business rules live here, with no HTTP code at all, so they are easy to test:
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Placed,
Packed,
Shipped,
Delivered,
Cancelled,
}
impl Status {
/// Is the move from `self` to `next` allowed?
pub fn can_move_to(self, next: Status) -> bool {
use Status::*;
matches!(
(self, next),
(Placed, Packed) | (Packed, Shipped) | (Shipped, Delivered) | (Placed | Packed, Cancelled)
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineItem {
pub product: String,
pub qty: u32,
pub unit_price_paise: u64,
}
#[derive(Debug, Deserialize)]
pub struct NewOrder {
pub customer: String,
pub city: String,
pub items: Vec<LineItem>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Order {
pub id: u32,
pub customer: String,
pub city: String,
pub items: Vec<LineItem>,
pub total_paise: u64,
pub status: Status,
}
impl NewOrder {
pub fn validate(&self) -> Result<(), String> {
if self.customer.trim().is_empty() {
return Err(String::from("customer name is required"));
}
if self.items.is_empty() {
return Err(String::from("an order needs at least one item"));
}
if let Some(item) = self.items.iter().find(|i| i.qty == 0) {
return Err(format!("quantity for {} must be at least 1", item.product));
}
Ok(())
}
pub fn total_paise(&self) -> u64 {
self.items.iter().map(|i| u64::from(i.qty) * i.unit_price_paise).sum()
}
}NewOrder is what the client sends; Order is what the server stores and returns, with the id, total_paise and status that only the server may set. Using two types means a client simply cannot send its own total or jump straight to delivered. The transition rule fits in one matches! call, and the pattern (Placed | Packed, Cancelled) covers both cancellable states.
Add unit tests at the bottom of the same file:
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> NewOrder {
NewOrder {
customer: String::from("Priya"),
city: String::from("Pune"),
items: vec![
LineItem { product: String::from("Notebook"), qty: 3, unit_price_paise: 4_500 },
LineItem { product: String::from("Pen"), qty: 10, unit_price_paise: 1_000 },
],
}
}
#[test]
fn total_is_in_paise() {
assert_eq!(sample().total_paise(), 23_500); // Rs. 135 + Rs. 100
}
#[test]
fn zero_quantity_is_rejected() {
let mut order = sample();
order.items[1].qty = 0;
assert_eq!(order.validate(), Err(String::from("quantity for Pen must be at least 1")));
}
#[test]
fn status_rules() {
assert!(Status::Placed.can_move_to(Status::Packed));
assert!(Status::Packed.can_move_to(Status::Cancelled));
assert!(!Status::Shipped.can_move_to(Status::Cancelled));
assert!(!Status::Delivered.can_move_to(Status::Placed));
}
}Step 4: one error type for the whole service
Create src/error.rs. thiserror writes the Display messages, and the IntoResponse implementation turns each error into a status code and a JSON body:
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
use crate::model::Status;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("order {0} not found")]
NotFound(u32),
#[error("{0}")]
Invalid(String),
#[error("cannot change status from {from:?} to {to:?}")]
BadTransition { from: Status, to: Status },
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match &self {
AppError::NotFound(_) => StatusCode::NOT_FOUND,
AppError::Invalid(_) => StatusCode::UNPROCESSABLE_ENTITY,
AppError::BadTransition { .. } => StatusCode::CONFLICT,
};
(status, Json(json!({ "error": self.to_string() }))).into_response()
}
}Every handler and store method returns Result<_, AppError>, so the mapping from errors to HTTP lives in exactly one place. If the shop later wants error codes in the JSON, you change only this file.
Step 5: the store
Create src/store.rs. It hides the lock and the ID counter behind four methods:
use std::collections::BTreeMap;
use std::sync::RwLock;
use crate::error::AppError;
use crate::model::{NewOrder, Order, Status};
#[derive(Default)]
pub struct OrderStore {
inner: RwLock<Inner>,
}
#[derive(Default)]
struct Inner {
next_id: u32,
orders: BTreeMap<u32, Order>,
}
impl OrderStore {
pub fn create(&self, new: NewOrder) -> Result<Order, AppError> {
new.validate().map_err(AppError::Invalid)?;
let mut inner = self.inner.write().unwrap();
inner.next_id += 1;
let order = Order {
id: inner.next_id,
total_paise: new.total_paise(),
customer: new.customer,
city: new.city,
items: new.items,
status: Status::Placed,
};
inner.orders.insert(order.id, order.clone());
Ok(order)
}
pub fn get(&self, id: u32) -> Result<Order, AppError> {
let inner = self.inner.read().unwrap();
inner.orders.get(&id).cloned().ok_or(AppError::NotFound(id))
}
pub fn list(&self, status: Option<Status>) -> Vec<Order> {
let inner = self.inner.read().unwrap();
inner.orders.values().filter(|o| status.is_none_or(|s| o.status == s)).cloned().collect()
}
pub fn set_status(&self, id: u32, next: Status) -> Result<Order, AppError> {
let mut inner = self.inner.write().unwrap();
let order = inner.orders.get_mut(&id).ok_or(AppError::NotFound(id))?;
if !order.status.can_move_to(next) {
return Err(AppError::BadTransition { from: order.status, to: next });
}
order.status = next;
Ok(order.clone())
}
}- The methods take
&self, not&mut self: theRwLockprovides the mutation (interior mutability, see Interior Mutability: Cell, OnceLock & Atomics). That is what lets many requests share one store through anArc. - A
BTreeMapkeeps orders sorted by ID, so lists come out in a stable order. - In the struct literal,
total_paiseis computed first, whilenewis still whole; the following lines then move its fields into the order without cloning. - Validation happens before taking the lock, so the lock is held as briefly as possible.
Because the rest of the program only sees these four methods, you can later replace the map with a PostgreSQL database (for example with the sqlx crate) without touching the handlers' logic.
Step 6: the handlers
Create src/routes.rs. Each handler is now only a few lines, because the real work is in the store:
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use crate::error::AppError;
use crate::model::{NewOrder, Order, Status};
use crate::store::OrderStore;
pub type AppState = Arc<OrderStore>;
pub async fn create_order(
State(store): State<AppState>,
Json(new): Json<NewOrder>,
) -> Result<(StatusCode, Json<Order>), AppError> {
let order = store.create(new)?;
Ok((StatusCode::CREATED, Json(order)))
}
pub async fn get_order(
State(store): State<AppState>,
Path(id): Path<u32>,
) -> Result<Json<Order>, AppError> {
store.get(id).map(Json)
}
#[derive(Deserialize)]
pub struct ListFilter {
status: Option<Status>,
}
pub async fn list_orders(
State(store): State<AppState>,
Query(filter): Query<ListFilter>,
) -> Json<Vec<Order>> {
Json(store.list(filter.status))
}
#[derive(Deserialize)]
pub struct StatusChange {
status: Status,
}
pub async fn change_status(
State(store): State<AppState>,
Path(id): Path<u32>,
Json(change): Json<StatusChange>,
) -> Result<Json<Order>, AppError> {
store.set_status(id, change.status).map(Json)
}serde does the input checking for free: ?status=packed becomes Some(Status::Packed) because of the snake_case renaming, and an unknown status such as "lost" is rejected by the Json or Query extractor before your code runs.
Step 7: wire up lib.rs and main.rs
src/lib.rs declares the modules and builds the router:
pub mod error;
pub mod model;
pub mod routes;
pub mod store;
use std::sync::Arc;
use axum::routing::{get, patch, post};
use axum::Router;
use tower_http::trace::TraceLayer;
pub fn app() -> Router {
let state: routes::AppState = Arc::new(store::OrderStore::default());
Router::new()
.route("/health", get(|| async { "ok" }))
.route("/orders", post(routes::create_order).get(routes::list_orders))
.route("/orders/{id}", get(routes::get_order))
.route("/orders/{id}/status", patch(routes::change_status))
.layer(TraceLayer::new_for_http())
.with_state(state)
}src/main.rs only sets up logging, reads the address from an environment variable and serves the app:
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let addr = std::env::var("ORDERS_ADDR").unwrap_or_else(|_| String::from("127.0.0.1:3000"));
let listener = tokio::net::TcpListener::bind(&addr).await.expect("could not bind address");
tracing::info!("orders-api listening on {addr}");
axum::serve(listener, orders_api::app())
.with_graceful_shutdown(async {
tokio::signal::ctrl_c().await.expect("could not listen for Ctrl+C");
})
.await
.expect("server error");
}Reading ORDERS_ADDR means the same binary can listen on 0.0.0.0:8080 inside a container without a code change.
Step 8: integration tests
Create tests/api.rs. The helper send builds an HTTP request, passes it to the router with oneshot and returns the status and the body as JSON. No network port is used, so the tests are fast and can run in parallel:
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::Router;
use http_body_util::BodyExt;
use serde_json::{json, Value};
use tower::ServiceExt;
async fn send(app: Router, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
let builder = Request::builder().method(method).uri(uri);
let request = match body {
Some(json) => builder
.header("content-type", "application/json")
.body(Body::from(json.to_string()))
.unwrap(),
None => builder.body(Body::empty()).unwrap(),
};
let response = app.oneshot(request).await.unwrap();
let status = response.status();
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let value = serde_json::from_slice(&bytes)
.unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned()));
(status, value)
}
fn sample_order() -> Value {
json!({
"customer": "Priya",
"city": "Pune",
"items": [
{ "product": "Notebook", "qty": 3, "unit_price_paise": 4500 },
{ "product": "Pen", "qty": 10, "unit_price_paise": 1000 }
]
})
}
#[tokio::test]
async fn create_then_fetch_order() {
let app = orders_api::app();
let (status, created) = send(app.clone(), "POST", "/orders", Some(sample_order())).await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(created["id"], 1);
assert_eq!(created["total_paise"], 23500);
assert_eq!(created["status"], "placed");
let (status, fetched) = send(app, "GET", "/orders/1", None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(fetched, created);
}
#[tokio::test]
async fn unknown_order_gives_json_404() {
let (status, body) = send(orders_api::app(), "GET", "/orders/42", None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["error"], "order 42 not found");
}
#[tokio::test]
async fn empty_order_is_rejected() {
let bad = json!({ "customer": "Rohan", "city": "Rohtak", "items": [] });
let (status, body) = send(orders_api::app(), "POST", "/orders", Some(bad)).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(body["error"], "an order needs at least one item");
}
#[tokio::test]
async fn wrong_json_type_is_rejected_by_axum() {
let mut bad = sample_order();
bad["items"][0]["qty"] = json!("three");
let (status, _) = send(orders_api::app(), "POST", "/orders", Some(bad)).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn status_follows_the_rules() {
let app = orders_api::app();
send(app.clone(), "POST", "/orders", Some(sample_order())).await;
let packed = json!({ "status": "packed" });
let (status, _) = send(app.clone(), "PATCH", "/orders/1/status", Some(packed)).await;
assert_eq!(status, StatusCode::OK);
let shipped = json!({ "status": "shipped" });
let (status, _) = send(app.clone(), "PATCH", "/orders/1/status", Some(shipped)).await;
assert_eq!(status, StatusCode::OK);
let cancel = json!({ "status": "cancelled" });
let (status, body) = send(app, "PATCH", "/orders/1/status", Some(cancel)).await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(body["error"], "cannot change status from Shipped to Cancelled");
}
#[tokio::test]
async fn list_can_filter_by_status() {
let app = orders_api::app();
send(app.clone(), "POST", "/orders", Some(sample_order())).await;
send(app.clone(), "POST", "/orders", Some(sample_order())).await;
send(app.clone(), "PATCH", "/orders/2/status", Some(json!({ "status": "packed" }))).await;
let (_, all) = send(app.clone(), "GET", "/orders", None).await;
let (_, placed) = send(app, "GET", "/orders?status=placed", None).await;
assert_eq!(all.as_array().unwrap().len(), 2);
assert_eq!(placed.as_array().unwrap().len(), 1);
assert_eq!(placed[0]["id"], 1);
}Two details matter here. First, oneshot consumes the router, so a test that sends several requests passes app.clone(). Cloning a Router is cheap and the clones share the same Arc<OrderStore>, so the second request sees the order created by the first. Second, each test calls orders_api::app() for a fresh, empty store, which is why every test can expect its first order to get ID 1 even though tests run at the same time. The #[tokio::test] attribute gives each async test its own runtime.
Run everything:
cargo testrunning 3 tests test model::tests::status_rules ... ok test model::tests::total_is_in_paise ... ok test model::tests::zero_quantity_is_rejected ... ok test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s running 6 tests test create_then_fetch_order ... ok test empty_order_is_rejected ... ok test list_can_filter_by_status ... ok test status_follows_the_rules ... ok test unknown_order_gives_json_404 ... ok test wrong_json_type_is_rejected_by_axum ... ok test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Cargo's Compiling and Running lines and the empty groups (main.rs and doc tests, with 0 tests each) are left out. The order of the test lines and the times can differ on your machine.
Step 9: run it and try it
Start the server with cargo run. The first log line looks like this (your timestamp will differ):
2026-11-02T10:15:30.481230Z INFO orders_api: orders-api listening on 127.0.0.1:3000
In a second terminal (Git Bash, Linux or macOS), place an order for Ananya in Kochi, pack it, list packed orders, and then try a move the rules forbid. -w "\n" adds a newline after each response:
curl -s -w "\n" -X POST http://127.0.0.1:3000/orders -H "Content-Type: application/json" -d '{"customer":"Ananya","city":"Kochi","items":[{"product":"Fountain pen","qty":2,"unit_price_paise":24000}]}'
curl -s -w "\n" -X PATCH http://127.0.0.1:3000/orders/1/status -H "Content-Type: application/json" -d '{"status":"packed"}'
curl -s -w "\n" "http://127.0.0.1:3000/orders?status=packed"
curl -s -w " %{http_code}\n" -X PATCH http://127.0.0.1:3000/orders/1/status -H "Content-Type: application/json" -d '{"status":"delivered"}'{"id":1,"customer":"Ananya","city":"Kochi","items":[{"product":"Fountain pen","qty":2,"unit_price_paise":24000}],"total_paise":48000,"status":"placed"}
{"id":1,"customer":"Ananya","city":"Kochi","items":[{"product":"Fountain pen","qty":2,"unit_price_paise":24000}],"total_paise":48000,"status":"packed"}
[{"id":1,"customer":"Ananya","city":"Kochi","items":[{"product":"Fountain pen","qty":2,"unit_price_paise":24000}],"total_paise":48000,"status":"packed"}]
{"error":"cannot change status from Packed to Delivered"} 409Two pens at Rs. 240 make Rs. 480, stored as 48000 paise. A packed order must be shipped before it can be delivered, so the last request gets 409 Conflict. Press Ctrl+C in the server terminal to stop it gracefully.
Finally, run the same checks a CI server would, as described in Clippy, rustfmt & CI Checks:
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
cargo build --releaseIdeas to extend the project
- Store orders in PostgreSQL or SQLite with
sqlx, and keep the same four store methods. - Add
DELETE /orders/{id}that is allowed only for cancelled orders, with a test. - Add paging to
GET /orderswith?page=2&per_page=20, and rejectper_pageabove 100. - Add an
X-Api-Keycheck as middleware, reading the expected key from an environment variable, never from the source code. - Write a multi-stage
Dockerfileand run the release binary withORDERS_ADDR=0.0.0.0:8080.
Key points
- Put the router in the library (
app()inlib.rs) and keepmain.rsthin, so integration tests can build the same app. - Keep business rules in plain Rust types with unit tests; handlers should only extract, call and respond.
- Separate input types (
NewOrder) from stored types (Order) so clients cannot set server-owned fields. - Store money as integer paise, never as floating-point rupees.
- One error enum with
thiserrorandIntoResponsegives consistent JSON errors and status codes. tower::ServiceExt::oneshottests the full HTTP stack without opening a port; clone the router to share state between requests.
Exercise
Add the DELETE /orders/{id} route from the ideas list. Deleting is allowed only when the order's status is cancelled; otherwise return 409 Conflict with the message only cancelled orders can be deleted. A successful delete returns 204 No Content with an empty body. Add a store method, a new AppError variant, the handler, the route, and an integration test that creates an order, fails to delete it, cancels it and then deletes it.
Show solution
// error.rs: a new variant (map it to StatusCode::CONFLICT in into_response)
#[error("only cancelled orders can be deleted")]
NotCancelled,
// store.rs
pub fn delete(&self, id: u32) -> Result<(), AppError> {
let mut inner = self.inner.write().unwrap();
let order = inner.orders.get(&id).ok_or(AppError::NotFound(id))?;
if order.status != Status::Cancelled {
return Err(AppError::NotCancelled);
}
inner.orders.remove(&id);
Ok(())
}
// routes.rs
pub async fn delete_order(
State(store): State<AppState>,
Path(id): Path<u32>,
) -> Result<StatusCode, AppError> {
store.delete(id)?;
Ok(StatusCode::NO_CONTENT)
}
// lib.rs
// .route("/orders/{id}", get(routes::get_order).delete(routes::delete_order))
// tests/api.rs
#[tokio::test]
async fn only_cancelled_orders_can_be_deleted() {
let app = orders_api::app();
send(app.clone(), "POST", "/orders", Some(sample_order())).await;
let (status, body) = send(app.clone(), "DELETE", "/orders/1", None).await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(body["error"], "only cancelled orders can be deleted");
let cancel = json!({ "status": "cancelled" });
send(app.clone(), "PATCH", "/orders/1/status", Some(cancel)).await;
let (status, _) = send(app.clone(), "DELETE", "/orders/1", None).await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, _) = send(app, "GET", "/orders/1", None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}In delete, the shared borrow from get ends after the status check, so remove can borrow the map mutably afterwards. Returning a bare StatusCode is a valid response with an empty body. The new variant also needs the arm AppError::NotCancelled => StatusCode::CONFLICT in into_response.
Quick Quiz
1. Why does the project keep app() in lib.rs instead of building the router in main.rs?
Integration tests are separate crates that see only the library, so the router must live there.
2. Why are prices stored as u64 paise rather than f64 rupees?
Whole numbers of paise add and multiply exactly.
3. In the tests, why is app.clone() passed to send when a test makes several requests?
A cloned Router is cheap and points at the same shared state.
4. What does a PATCH /orders/1/status request to delivered return for an order that is only packed?
The store checks the transition rule and returns AppError::BadTransition, which maps to 409.
Learn Rust with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
