Lesson 4 of 6
Web Services with axum
axum is the most widely used Rust web framework. It's built by the Tokio team on top of hyper (the HTTP implementation) and tower (a library of reusable middleware). Its programming model is deliberately plain: there are no annotations, no runtime scanning, and no reflection. A handler is an ordinary async fn; its parameters say what it needs from the request, and its return type says what it sends back. A router maps paths to handlers in plain code.
cargo add axum
cargo add tokio --features full
cargo add serde --features derive
cargo add tower --features util # for testing handlers in-process
cargo add tower-http --features trace,cors| Concept | axum |
|---|---|
| Request handlers | async fn handlers + a Router |
| Mapping a route | .route("/users/{id}", get(handler)) |
| Path parameters | Path<T> |
| Query parameters | Query<T> |
| JSON request body | Json<T> |
| Response with a status code | (StatusCode, Json<T>), or any impl IntoResponse |
| Central error handling | an error type that implements IntoResponse |
| Shared dependencies | State<T> |
| Request filters and interceptors | middleware (middleware::from_fn, tower layers) |
| In-process tests | tower::ServiceExt::oneshot |
| Running the server | axum::serve on a Tokio TcpListener |
It helps to have read Async Fundamentals and Serialization with serde.
Part 1: Routes and handlers
1. Hello, axum
A complete server: a router with one route, a Tokio TcpListener, and axum::serve. The handler returns a &'static str, which axum turns into a 200 OK response with a text/plain body.
use axum::{Router, routing::get};
async fn hello() -> &'static str {
"Hello from axum!"
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(hello));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}$ cargo run
listening on http://127.0.0.1:3000
$ curl -i http://127.0.0.1:3000/
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 16
Hello from axum!2. Calling the app without a network
A Router is a tower Service: something that takes a request and asynchronously returns a response. oneshot from tower::ServiceExt sends it a single request directly, with no port and no server. Every following example uses this small send helper to show requests and responses.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::{Router, routing::get};
use tower::ServiceExt;
async fn hello() -> &'static str {
"Hello from axum!"
}
async fn send(app: &Router, method: &str, uri: &str, body: &str) -> (StatusCode, String) {
let request = Request::builder().method(method).uri(uri).header("content-type", "application/json").body(Body::from(body.to_string())).unwrap();
let response = app.clone().oneshot(request).await.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(hello));
println!("{:?}", send(&app, "GET", "/", "").await);
println!("{:?}", send(&app, "POST", "/", "").await); // route exists, method doesn't
println!("{:?}", send(&app, "GET", "/missing", "").await);
}(200, "Hello from axum!")
(405, "")
(404, "")3. Path and query parameters
Handler parameters are extractors: each one pulls something out of the request. Path<T> parses the {...} segments of the route, and Query<T> deserializes the query string into a struct with serde. If extraction fails, axum answers with a 400 and an explanation, and the handler never runs.
use axum::body::Body;
use axum::extract::{Path, Query};
use axum::http::{Request, StatusCode};
use axum::{Router, routing::get};
use serde::Deserialize;
use tower::ServiceExt;
#[derive(Deserialize)]
struct Paging {
#[serde(default)]
page: u32,
#[serde(default = "default_size")]
size: u32,
}
fn default_size() -> u32 {
20
}
async fn user_orders(Path((user_id, status)): Path<(u64, String)>, Query(paging): Query<Paging>) -> String {
format!("orders of user {user_id} with status {status}, page {} of size {}", paging.page, paging.size)
}
async fn send(app: &Router, method: &str, uri: &str, body: &str) -> (StatusCode, String) {
let request = Request::builder().method(method).uri(uri).header("content-type", "application/json").body(Body::from(body.to_string())).unwrap();
let response = app.clone().oneshot(request).await.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/users/{user_id}/orders/{status}", get(user_orders));
for uri in ["/users/42/orders/shipped", "/users/42/orders/open?page=3&size=50", "/users/ana/orders/open", "/users/42/orders/open?page=-1"] {
println!("{uri}\n {:?}", send(&app, "GET", uri, "").await);
}
}/users/42/orders/shipped
(200, "orders of user 42 with status shipped, page 0 of size 20")
/users/42/orders/open?page=3&size=50
(200, "orders of user 42 with status open, page 3 of size 50")
/users/ana/orders/open
(400, "Invalid URL: Cannot parse value at index 0 with value `ana` to a `u64`")
/users/42/orders/open?page=-1
(400, "Failed to deserialize query string: page: invalid digit found in string")4. JSON in, JSON out
Json<T> as a parameter deserializes the request body; as a return value, it serializes the response and sets the content-type. Returning a tuple (StatusCode, Json<T>) sets the status too, like ResponseEntity.status(201).body(user). Malformed JSON gets a 400 and JSON that doesn't match the struct a 422, before your code runs.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::{Json, Router, routing::post};
use serde::{Deserialize, Serialize};
use tower::ServiceExt;
#[derive(Deserialize)]
struct NewUser {
name: String,
email: String,
}
#[derive(Serialize)]
struct User {
id: u64,
name: String,
email: String,
}
async fn create_user(Json(input): Json<NewUser>) -> (StatusCode, Json<User>) {
let user = User { id: 1, name: input.name, email: input.email.to_lowercase() };
(StatusCode::CREATED, Json(user))
}
async fn send(app: &Router, method: &str, uri: &str, body: &str) -> (StatusCode, String) {
let request = Request::builder().method(method).uri(uri).header("content-type", "application/json").body(Body::from(body.to_string())).unwrap();
let response = app.clone().oneshot(request).await.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/users", post(create_user));
for body in [r#"{"name": "Ana", "email": "Ana@Example.com"}"#, r#"{"name": "Ben"}"#, r#"{"name": "Cy","#] {
println!("{:?}", send(&app, "POST", "/users", body).await);
}
}(201, "{\"id\":1,\"name\":\"Ana\",\"email\":\"ana@example.com\"}")
(422, "Failed to deserialize the JSON body into the target type: missing field `email` at line 1 column 15")
(400, "Failed to parse the request body as JSON: EOF while parsing a value at line 1 column 14")Part 2: State and errors
5. Shared state
Handlers are plain functions, so anything they share, such as a database pool, configuration, or an in-memory store, is passed in with .with_state(...) and extracted with State<T>. The state is cloned for each request, so it's usually an Arc around a struct, with a Mutex or RwLock for data that changes (Shared State).
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::{Request, StatusCode};
use axum::{Json, Router, routing::get};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use tower::ServiceExt;
#[derive(Clone, Serialize)]
struct Product {
id: u32,
name: String,
stock: u32,
}
#[derive(Deserialize)]
struct NewProduct {
name: String,
stock: u32,
}
#[derive(Default)]
struct AppState {
products: Mutex<BTreeMap<u32, Product>>,
}
type Shared = Arc<AppState>;
async fn list(State(state): State<Shared>) -> Json<Vec<Product>> {
Json(state.products.lock().unwrap().values().cloned().collect())
}
async fn create(State(state): State<Shared>, Json(input): Json<NewProduct>) -> (StatusCode, Json<Product>) {
let mut products = state.products.lock().unwrap();
let id = products.keys().last().map_or(1, |last| last + 1);
let product = Product { id, name: input.name, stock: input.stock };
products.insert(id, product.clone());
(StatusCode::CREATED, Json(product))
}
async fn get_one(State(state): State<Shared>, Path(id): Path<u32>) -> Result<Json<Product>, StatusCode> {
state.products.lock().unwrap().get(&id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}
async fn send(app: &Router, method: &str, uri: &str, body: &str) -> (StatusCode, String) {
let request = Request::builder().method(method).uri(uri).header("content-type", "application/json").body(Body::from(body.to_string())).unwrap();
let response = app.clone().oneshot(request).await.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/products", get(list).post(create))
.route("/products/{id}", get(get_one))
.with_state(Arc::new(AppState::default()));
println!("{:?}", send(&app, "POST", "/products", r#"{"name": "lamp", "stock": 12}"#).await);
println!("{:?}", send(&app, "POST", "/products", r#"{"name": "desk", "stock": 3}"#).await);
println!("{:?}", send(&app, "GET", "/products", "").await);
println!("{:?}", send(&app, "GET", "/products/2", "").await);
println!("{:?}", send(&app, "GET", "/products/9", "").await);
}(201, "{\"id\":1,\"name\":\"lamp\",\"stock\":12}")
(201, "{\"id\":2,\"name\":\"desk\",\"stock\":3}")
(200, "[{\"id\":1,\"name\":\"lamp\",\"stock\":12},{\"id\":2,\"name\":\"desk\",\"stock\":3}]")
(200, "{\"id\":2,\"name\":\"desk\",\"stock\":3}")
(404, "")The std::sync::Mutex is fine here because it's never held across an .await. If a handler needs to keep a lock while awaiting, use tokio::sync::Mutex (Async Channels & Locks).
6. Errors that become responses
A handler can return Result<T, E> for any error type E that implements IntoResponse. Define one error enum for the application, decide in one place how each variant maps to a status code and a JSON body, and use ? in the handlers. Error handling lives in one place, checked by the compiler.
use axum::body::Body;
use axum::extract::Path;
use axum::http::{Request, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::{Json, Router, routing::get};
use serde_json::json;
use tower::ServiceExt;
enum AppError {
NotFound(String),
Invalid(String),
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound(what) => (StatusCode::NOT_FOUND, format!("{what} not found")),
AppError::Invalid(why) => (StatusCode::UNPROCESSABLE_ENTITY, why),
AppError::Internal(detail) => {
eprintln!("internal error: {detail}"); // log the details, don't send them
(StatusCode::INTERNAL_SERVER_ERROR, String::from("something went wrong"))
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
impl From<std::num::ParseIntError> for AppError {
fn from(e: std::num::ParseIntError) -> Self {
AppError::Invalid(format!("not a number: {e}"))
}
}
async fn invoice(Path(raw_id): Path<String>) -> Result<Json<serde_json::Value>, AppError> {
let id: u32 = raw_id.parse()?; // ParseIntError -> AppError::Invalid via From
match id {
1 => Ok(Json(json!({ "id": 1, "total": 4990 }))),
500 => Err(AppError::Internal(String::from("database connection reset"))),
_ => Err(AppError::NotFound(format!("invoice {id}"))),
}
}
async fn send(app: &Router, method: &str, uri: &str, body: &str) -> (StatusCode, String) {
let request = Request::builder().method(method).uri(uri).header("content-type", "application/json").body(Body::from(body.to_string())).unwrap();
let response = app.clone().oneshot(request).await.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/invoices/{id}", get(invoice));
for uri in ["/invoices/1", "/invoices/7", "/invoices/abc", "/invoices/500"] {
println!("{uri:<14} {:?}", send(&app, "GET", uri, "").await);
}
}/invoices/1 (200, "{\"id\":1,\"total\":4990}")
/invoices/7 (404, "{\"error\":\"invoice 7 not found\"}")
/invoices/abc (422, "{\"error\":\"not a number: invalid digit found in string\"}")
/invoices/500 (500, "{\"error\":\"something went wrong\"}")In a real application, AppError often derives thiserror::Error and has #[from] variants for sqlx::Error and other library errors, so ? works on all of them.
7. Status codes, headers, and other responses
Anything that implements IntoResponse can be returned, and tuples combine parts: a status, headers as [(name, value)], and a body. Html sets an HTML content type and Redirect builds redirects. When one handler returns different types on different paths, convert each with .into_response().
use axum::body::Body;
use axum::extract::Path;
use axum::http::{HeaderMap, Request, StatusCode, header};
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::{Router, routing::get};
use tower::ServiceExt;
async fn created() -> impl IntoResponse {
(StatusCode::CREATED, [(header::LOCATION, "/items/17"), (header::CACHE_CONTROL, "no-store")], "created item 17")
}
async fn page(Path(name): Path<String>) -> Response {
match name.as_str() {
"old-home" => Redirect::permanent("/home").into_response(),
"home" => Html("<h1>Welcome</h1>").into_response(),
_ => StatusCode::NOT_FOUND.into_response(),
}
}
async fn whoami(headers: HeaderMap) -> String {
let agent = headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or("unknown");
format!("your user agent is {agent}")
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/items", get(created)).route("/pages/{name}", get(page)).route("/whoami", get(whoami));
for uri in ["/items", "/pages/home", "/pages/old-home", "/pages/other", "/whoami"] {
let request = Request::builder().uri(uri).header(header::USER_AGENT, "curl/8.5").body(Body::empty()).unwrap();
let response = app.clone().oneshot(request).await.unwrap();
let interesting: Vec<String> = [header::LOCATION, header::CONTENT_TYPE, header::CACHE_CONTROL]
.iter()
.filter_map(|h| response.headers().get(h).map(|v| format!("{h}: {}", v.to_str().unwrap())))
.collect();
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
println!("{uri:<16} {status} {interesting:?} {:?}", String::from_utf8_lossy(&body));
}
}/items 201 Created ["location: /items/17", "content-type: text/plain; charset=utf-8", "cache-control: no-store"] "created item 17"
/pages/home 200 OK ["content-type: text/html; charset=utf-8"] "<h1>Welcome</h1>"
/pages/old-home 308 Permanent Redirect ["location: /home"] ""
/pages/other 404 Not Found [] ""
/whoami 200 OK ["content-type: text/plain; charset=utf-8"] "your user agent is curl/8.5"Part 3: Structure and middleware
8. Organizing routes
Routers compose. nest mounts a router under a path prefix, merge combines routers, one route can have several methods (get(...).post(...)), and fallback handles every request that matched nothing, here with a JSON 404 instead of an empty one.
use axum::body::Body;
use axum::http::{Request, StatusCode, Uri};
use axum::{Json, Router, routing::get};
use serde_json::{Value, json};
use tower::ServiceExt;
fn users_routes() -> Router {
Router::new().route("/", get(|| async { "list users" }).post(|| async { (StatusCode::CREATED, "create user") })).route("/{id}", get(|| async { "one user" }))
}
fn orders_routes() -> Router {
Router::new().route("/", get(|| async { "list orders" }))
}
async fn not_found(uri: Uri) -> (StatusCode, Json<Value>) {
(StatusCode::NOT_FOUND, Json(json!({ "error": format!("no route for {uri}") })))
}
async fn send(app: &Router, method: &str, uri: &str, body: &str) -> (StatusCode, String) {
let request = Request::builder().method(method).uri(uri).header("content-type", "application/json").body(Body::from(body.to_string())).unwrap();
let response = app.clone().oneshot(request).await.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
#[tokio::main]
async fn main() {
let api = Router::new().nest("/users", users_routes()).nest("/orders", orders_routes());
let app = Router::new().nest("/api/v1", api).route("/health", get(|| async { "ok" })).fallback(not_found);
for (method, uri) in [("GET", "/api/v1/users"), ("POST", "/api/v1/users"), ("GET", "/api/v1/users/7"), ("GET", "/api/v1/orders"), ("GET", "/health"), ("GET", "/api/v2/users")] {
println!("{method:<4} {uri:<16} {:?}", send(&app, method, uri, "").await);
}
}GET /api/v1/users (200, "list users")
POST /api/v1/users (201, "create user")
GET /api/v1/users/7 (200, "one user")
GET /api/v1/orders (200, "list orders")
GET /health (200, "ok")
GET /api/v2/users (404, "{\"error\":\"no route for /api/v2/users\"}")Closures such as || async { "ok" } work as handlers too, which is handy for trivial routes.
9. Middleware
middleware::from_fn turns an async fn into middleware, like a servlet Filter: it receives the request and next, the rest of the stack. It can inspect or change the request, reject it early, or look at the response on the way out. .layer applies middleware to every route added before it; .route_layer only to matched routes.
use axum::body::Body;
use axum::extract::Request;
use axum::http::{StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::Response;
use axum::{Router, routing::get};
use tower::ServiceExt;
// Logs every request and its outcome.
async fn log_requests(request: Request, next: Next) -> Response {
let line = format!("{} {}", request.method(), request.uri().path());
let response = next.run(request).await;
println!(" [log] {line} -> {}", response.status());
response
}
// Rejects requests without the right API key before they reach a handler.
async fn require_api_key(request: Request, next: Next) -> Result<Response, StatusCode> {
match request.headers().get("x-api-key").and_then(|v| v.to_str().ok()) {
Some("secret-key") => Ok(next.run(request).await),
_ => Err(StatusCode::UNAUTHORIZED),
}
}
#[tokio::main]
async fn main() {
let admin = Router::new().route("/admin/stats", get(|| async { "42 users" })).route_layer(middleware::from_fn(require_api_key));
let app = Router::new().route("/", get(|| async { "home" })).merge(admin).layer(middleware::from_fn(log_requests));
for (uri, key) in [("/", None), ("/admin/stats", None), ("/admin/stats", Some("wrong")), ("/admin/stats", Some("secret-key"))] {
let mut request = axum::http::Request::builder().uri(uri);
if let Some(key) = key {
request = request.header("x-api-key", key);
}
let response = app.clone().oneshot(request.header(header::ACCEPT, "*/*").body(Body::empty()).unwrap()).await.unwrap();
println!("{uri} with key {key:?}: {}", response.status());
}
} [log] GET / -> 200 OK
/ with key None: 200 OK
[log] GET /admin/stats -> 401 Unauthorized
/admin/stats with key None: 401 Unauthorized
[log] GET /admin/stats -> 401 Unauthorized
/admin/stats with key Some("wrong"): 401 Unauthorized
[log] GET /admin/stats -> 200 OK
/admin/stats with key Some("secret-key"): 200 OK10. A custom extractor for the current user
Authentication often belongs in an extractor rather than middleware: implement FromRequestParts for a type, and any handler that lists it as a parameter gets an authenticated user, or never runs. The handler's signature then documents that it requires login.
use axum::body::Body;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::{Request, StatusCode, header};
use axum::{Router, routing::get};
use tower::ServiceExt;
struct CurrentUser {
name: String,
admin: bool,
}
impl<S: Send + Sync> FromRequestParts<S> for CurrentUser {
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let token = parts
.headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or((StatusCode::UNAUTHORIZED, "missing bearer token"))?;
// A real app would verify a signed token or look up a session here.
match token {
"token-ana" => Ok(CurrentUser { name: String::from("ana"), admin: true }),
"token-ben" => Ok(CurrentUser { name: String::from("ben"), admin: false }),
_ => Err((StatusCode::UNAUTHORIZED, "invalid token")),
}
}
}
async fn profile(user: CurrentUser) -> String {
format!("hello {}", user.name)
}
async fn admin_panel(user: CurrentUser) -> Result<&'static str, StatusCode> {
if user.admin { Ok("admin panel") } else { Err(StatusCode::FORBIDDEN) }
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/me", get(profile)).route("/admin", get(admin_panel));
for (uri, auth) in [("/me", None), ("/me", Some("Bearer nope")), ("/me", Some("Bearer token-ben")), ("/admin", Some("Bearer token-ben")), ("/admin", Some("Bearer token-ana"))] {
let mut request = Request::builder().uri(uri);
if let Some(auth) = auth {
request = request.header(header::AUTHORIZATION, auth);
}
let response = app.clone().oneshot(request.body(Body::empty()).unwrap()).await.unwrap();
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
println!("{uri:<7} {:<18} {status} {:?}", auth.unwrap_or("-"), String::from_utf8_lossy(&body));
}
}/me - 401 Unauthorized "missing bearer token"
/me Bearer nope 401 Unauthorized "invalid token"
/me Bearer token-ben 200 OK "hello ben"
/admin Bearer token-ben 403 Forbidden ""
/admin Bearer token-ana 200 OK "admin panel"11. Ready-made middleware from tower-http
tower-http provides production middleware that works with any tower-based server: request tracing, CORS, compression, timeouts, request IDs, and more. TraceLayer creates a tracing span for each request (Logging & Tracing); here it's configured to log one line per response.
use axum::body::Body;
use axum::http::{Method, Request, Response, header};
use axum::{Router, routing::get};
use std::time::Duration;
use tower::ServiceExt;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use tracing::{Span, info, info_span};
#[tokio::main]
async fn main() {
tracing_subscriber::fmt().without_time().with_ansi(false).with_target(false).init();
let trace = TraceLayer::new_for_http()
.make_span_with(|request: &Request<Body>| info_span!("http", method = %request.method(), path = %request.uri().path()))
.on_request(())
.on_response(|response: &Response<Body>, _latency: Duration, _span: &Span| info!(status = response.status().as_u16(), "response"));
let cors = CorsLayer::new().allow_origin(["https://app.example.com".parse().unwrap()]).allow_methods([Method::GET, Method::POST]);
let app = Router::new().route("/orders", get(|| async { "[]" })).layer(trace).layer(cors);
let request = Request::builder().uri("/orders").header(header::ORIGIN, "https://app.example.com").body(Body::empty()).unwrap();
let response = app.clone().oneshot(request).await.unwrap();
println!("allow-origin: {:?}", response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN));
let missing = Request::builder().uri("/nope").body(Body::empty()).unwrap();
app.oneshot(missing).await.unwrap();
} INFO http{method=GET path=/orders}: response status=200
allow-origin: Some("https://app.example.com")
INFO http{method=GET path=/nope}: response status=404Part 4: Running it for real
12. A real server, with graceful shutdown
Binding to port 0 lets the operating system pick a free port, which is how tests start real servers without clashing. with_graceful_shutdown stops accepting new connections when a future completes and lets requests in progress finish; in production that future waits for Ctrl-C or SIGTERM (tokio::signal::ctrl_c()). The requests here use reqwest, the HTTP client from the next lesson.
use axum::extract::Path;
use axum::{Router, routing::get};
use tokio::sync::oneshot;
async fn greet(Path(name): Path<String>) -> String {
format!("hello, {name}")
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = Router::new().route("/greet/{name}", get(greet));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
println!("listening on a free port: {}", addr.port() > 0);
let (stop_tx, stop_rx) = oneshot::channel::<()>();
let server = tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async {
stop_rx.await.ok(); // in production: tokio::signal::ctrl_c().await
})
.await
});
let client = reqwest::Client::new();
for name in ["ana", "ben"] {
let body = client.get(format!("http://{addr}/greet/{name}")).send().await?.text().await?;
println!("{body}");
}
stop_tx.send(()).ok();
server.await??;
println!("server stopped cleanly");
Ok(())
}listening on a free port: true
hello, ana
hello, ben
server stopped cleanly13. Realistic example: a tested notes API
A small but complete REST API: create, list, read, update, and delete notes, with validation, an error type, shared state, and a test suite that exercises it in-process. The tests run with cargo test, and #[tokio::test] gives each test its own async runtime.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub struct Note {
id: u32,
title: String,
body: String,
}
#[derive(Deserialize)]
pub struct NoteInput {
title: String,
#[serde(default)]
body: String,
}
#[derive(Default)]
struct Store {
notes: BTreeMap<u32, Note>,
next_id: u32,
}
type AppState = Arc<Mutex<Store>>;
pub enum ApiError {
NotFound(u32),
Invalid(&'static str),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::NotFound(id) => (StatusCode::NOT_FOUND, format!("note {id} not found")),
ApiError::Invalid(why) => (StatusCode::UNPROCESSABLE_ENTITY, why.to_string()),
};
(status, Json(json!({ "error": message }))).into_response()
}
}
fn validate(input: &NoteInput) -> Result<(), ApiError> {
if input.title.trim().is_empty() {
return Err(ApiError::Invalid("title must not be empty"));
}
if input.title.len() > 100 {
return Err(ApiError::Invalid("title is too long"));
}
Ok(())
}
async fn list(State(state): State<AppState>) -> Json<Vec<Note>> {
Json(state.lock().unwrap().notes.values().cloned().collect())
}
async fn create(State(state): State<AppState>, Json(input): Json<NoteInput>) -> Result<(StatusCode, Json<Note>), ApiError> {
validate(&input)?;
let mut store = state.lock().unwrap();
store.next_id += 1;
let note = Note { id: store.next_id, title: input.title, body: input.body };
store.notes.insert(note.id, note.clone());
Ok((StatusCode::CREATED, Json(note)))
}
async fn read(State(state): State<AppState>, Path(id): Path<u32>) -> Result<Json<Note>, ApiError> {
state.lock().unwrap().notes.get(&id).cloned().map(Json).ok_or(ApiError::NotFound(id))
}
async fn update(State(state): State<AppState>, Path(id): Path<u32>, Json(input): Json<NoteInput>) -> Result<Json<Note>, ApiError> {
validate(&input)?;
let mut store = state.lock().unwrap();
let note = store.notes.get_mut(&id).ok_or(ApiError::NotFound(id))?;
note.title = input.title;
note.body = input.body;
Ok(Json(note.clone()))
}
async fn delete(State(state): State<AppState>, Path(id): Path<u32>) -> Result<StatusCode, ApiError> {
state.lock().unwrap().notes.remove(&id).map(|_| StatusCode::NO_CONTENT).ok_or(ApiError::NotFound(id))
}
pub fn app() -> Router {
Router::new()
.route("/notes", get(list).post(create))
.route("/notes/{id}", get(read).put(update).delete(delete))
.with_state(AppState::default())
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use serde_json::Value;
use tower::ServiceExt;
async fn call(app: &Router, method: &str, uri: &str, body: Value) -> (StatusCode, Value) {
let request = Request::builder().method(method).uri(uri).header("content-type", "application/json").body(Body::from(body.to_string())).unwrap();
let response = app.clone().oneshot(request).await.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}
#[tokio::test]
async fn create_then_read() {
let app = app();
let (status, created) = call(&app, "POST", "/notes", json!({ "title": "groceries", "body": "milk" })).await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(created["id"], 1);
let (status, read) = call(&app, "GET", "/notes/1", Value::Null).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(read, created);
}
#[tokio::test]
async fn validation_errors_are_422() {
let (status, body) = call(&app(), "POST", "/notes", json!({ "title": " " })).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(body["error"], "title must not be empty");
}
#[tokio::test]
async fn update_and_delete() {
let app = app();
call(&app, "POST", "/notes", json!({ "title": "draft" })).await;
let (status, updated) = call(&app, "PUT", "/notes/1", json!({ "title": "final", "body": "done" })).await;
assert_eq!((status, updated["title"].as_str()), (StatusCode::OK, Some("final")));
let (status, _) = call(&app, "DELETE", "/notes/1", Value::Null).await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, body) = call(&app, "GET", "/notes/1", Value::Null).await;
assert_eq!((status, body["error"].as_str()), (StatusCode::NOT_FOUND, Some("note 1 not found")));
}
#[tokio::test]
async fn list_returns_notes_in_id_order() {
let app = app();
for title in ["b", "a", "c"] {
call(&app, "POST", "/notes", json!({ "title": title })).await;
}
let (_, list) = call(&app, "GET", "/notes", Value::Null).await;
let titles: Vec<&str> = list.as_array().unwrap().iter().map(|n| n["title"].as_str().unwrap()).collect();
assert_eq!(titles, ["b", "a", "c"]);
}
}running 4 tests
test tests::create_then_read ... ok
test tests::list_returns_notes_in_id_order ... ok
test tests::update_and_delete ... ok
test tests::validation_errors_are_422 ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sThe main for this service would be the three lines from example 1 with app() as the router. The database lesson replaces the in-memory BTreeMap with SQLite.
Summary
- A handler is an
async fn: its parameters are extractors (Path,Query,Json,State,HeaderMap, your ownFromRequestPartstypes) and its return type implementsIntoResponse. - A
Routermaps paths (with{param}segments) and methods to handlers; routersnestandmerge, andfallbackcatches the rest. - Bad input is rejected with
400or422before your code runs. - Shared resources go in the state, typically an
Archolding a struct;.with_statepasses it in. - One error enum implementing
IntoResponseturns?into proper HTTP errors. - Middleware is a function with
middleware::from_fn, or a ready-made tower-http layer such asTraceLayerandCorsLayer. - Test with
oneshotin-process; run for real withaxum::serve, port0in tests, andwith_graceful_shutdown.
Next: HTTP Clients with reqwest.
