Lesson 1 of 30
Your First Axum Server
Axum is a Rust library for writing web servers. This lesson builds a products API one step at a time. Each example is a complete main.rs that runs, and each one adds a few lines to the one before. It assumes Rust basics and async/await, and nothing about web frameworks.
Words used in this lesson
| Term | Meaning |
|---|---|
| Server | A program that waits for requests and sends back responses. |
| Process | A running program. cargo run starts one. |
| Port | A number (like 3000) that picks which program on a computer receives a network connection. |
| HTTP request | A message from a client (such as curl or a browser) with a method, a path, and sometimes a body. |
| Method | The kind of request: GET reads, POST creates, PUT replaces, DELETE removes. |
| Path | The part of the URL after the host, like /products/7. |
| HTTP response | The reply: a status code, headers, and a body. |
| Status code | A three-digit number in the response. 200 means success, 404 means not found. |
| Handler | An async fn that receives a request's data and returns a response. |
| Router | The Axum object that maps a method and path to a handler. |
| Extractor | A function argument type that pulls data out of the request (path values, query values, JSON, shared state). |
| JSON | A text format for data, like {"name":"Pen"}. |
Project setup
Create the project with cargo new products-api, then replace Cargo.toml with this.
Cargo.toml
[package]
name = "products-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "net"] }axumis the web library.serdeconverts Rust structs to and from JSON. Thederivefeature allows#[derive(Serialize, Deserialize)]. It is first used in Example 3.tokiorunsasynccode.macrosenables#[tokio::main].rt-multi-threadprovides the thread pool that runs it.netprovidesTcpListener.
Each example replaces the whole src/main.rs. Run it with cargo run. Stop it with Ctrl+C before starting the next one, because two programs cannot use port 3000 at the same time.
Example 1: Hello world server
This is the smallest working server. It has one route and one handler.
src/main.rs
use axum::{routing::get, Router};
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();
axum::serve(listener, app).await.unwrap();
}What each line does:
use axum::{routing::get, Router};imports theRoutertype and thegetfunction. Withoutget, the route cannot be limited toGETrequests.async fn hello() -> &'static stris a handler. It takes no arguments and returns text. Axum turns a returned&'static strinto a response with status200 OKand the text as the body.#[tokio::main]turnsmaininto an async function by starting the Tokio runtime around it. Without it,.awaitdoes not compile inmain.Router::new()creates a router with no routes..route("/", get(hello))adds one route: aGETrequest to the path/callshello.TcpListener::bind("127.0.0.1:3000")opens port 3000 on this computer only.127.0.0.1is the address that means "this computer". Binding fails if another process already uses port 3000.axum::serve(listener, app)accepts connections and gives each request to the router. It runs until the process stops.
In a second terminal, send a request. curl -i prints the response headers as well as the body.
curl -i http://127.0.0.1:3000/HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 15
Hello from AxumThe response also has a date header, which is left out of this lesson's outputs because it changes on every request.
Example 2: Several routes and methods
This example adds a second route and a second method. Added: /health, and post on /products.
src/main.rs
use axum::{
routing::{get, post},
Router,
};
async fn hello() -> &'static str {
"Hello from Axum"
}
async fn health() -> &'static str {
"ok"
}
async fn list_products() -> &'static str {
"product list goes here"
}
async fn create_product() -> &'static str {
"product created"
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(hello))
.route("/health", get(health))
.route("/products", get(list_products).post(create_product));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}What each new line does:
routing::{get, post}imports the second method function..route("/products", get(list_products).post(create_product))attaches two handlers to one path. The method in the request picks the handler.
curl -i -X POST http://127.0.0.1:3000/productsHTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 15
product createdTwo common mistakes:
Wrong path. No route matches, so Axum answers 404 Not Found with an empty body.
curl -i http://127.0.0.1:3000/productHTTP/1.1 404 Not Found
content-length: 0Wrong method. The path exists, but not for this method. Axum answers 405 Method Not Allowed and lists the allowed methods in the allow header.
curl -i -X POST http://127.0.0.1:3000/healthHTTP/1.1 405 Method Not Allowed
allow: GET,HEAD
content-length: 0Example 3: Path and query values
A request can carry values in the path (/products/7) and after a ? in the URL (/search?name=pen&limit=5). The second kind is called the query string. Added: two extractors, Path and Query.
src/main.rs
use axum::{
extract::{Path, Query},
routing::get,
Router,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct SearchParams {
name: String,
limit: Option<u32>,
}
async fn get_product(Path(id): Path<u32>) -> String {
format!("Product number {id}")
}
async fn search(Query(params): Query<SearchParams>) -> String {
let limit = params.limit.unwrap_or(10);
format!("Searching for {} (limit {})", params.name, limit)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/products/{id}", get(get_product))
.route("/search", get(search));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}What each new line does:
"/products/{id}"is a path with a named part.{id}matches any one segment, such as7. This is the Axum 0.8 syntax. Older versions wrote:id.Path(id): Path<u32>is an extractor argument. Axum reads the{id}part of the path and converts it to au32. ThePath(id)pattern on the left unwraps the value soidis a plainu32.#[derive(Deserialize)]lets Axum buildSearchParamsfrom the query string. Each field is one query key.limit: Option<u32>makeslimitoptional. A missinglimitgivesNone. A missingnameis an error, becausenameis not anOption.Query(params): Query<SearchParams>extracts the query string into the struct.format!builds aString. A returnedStringbecomes a200 OKtext response, like&'static str.
curl -i http://127.0.0.1:3000/products/7HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 16
Product number 7curl -i "http://127.0.0.1:3000/search?name=pen&limit=5"HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 29
Searching for pen (limit 5)The quotes around the URL stop the shell from treating & as a command separator.
Common mistake: /products/abc cannot be converted to a u32. The handler is never called. Axum answers 400 Bad Request with the reason as the body.
curl -i http://127.0.0.1:3000/products/abcHTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
content-length: 42
Invalid URL: Cannot parse `abc` to a `u32`A missing name in /search is also rejected before the handler runs, with status 400 Bad Request and a message that names the failed query string.
Example 4: JSON in and out
Real APIs send and receive JSON. Added: the Json type, a Product struct, and a NewProduct struct for incoming data.
src/main.rs
use axum::{routing::get, Json, Router};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct Product {
id: u32,
name: String,
price_cents: u32,
}
#[derive(Deserialize)]
struct NewProduct {
name: String,
price_cents: u32,
}
async fn list_products() -> Json<Vec<Product>> {
Json(vec![Product {
id: 1,
name: "Pen".to_string(),
price_cents: 150,
}])
}
async fn create_product(Json(input): Json<NewProduct>) -> Json<Product> {
Json(Product {
id: 1,
name: input.name,
price_cents: input.price_cents,
})
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/products", get(list_products).post(create_product));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}What each new line does:
#[derive(Serialize)]lets a struct be turned into JSON. It is used on data the server sends.#[derive(Deserialize)]lets a struct be built from JSON. It is used on data the server receives.Json<Vec<Product>>as a return type means "send this value as JSON". Axum addscontent-type: application/json.Json(input): Json<NewProduct>as an argument means "read the request body as JSON and build aNewProduct". The id is fixed at1for now. Example 6 assigns real ids.
curl -i http://127.0.0.1:3000/productsHTTP/1.1 200 OK
content-type: application/json
[{"id":1,"name":"Pen","price_cents":150}]curl -i -X POST http://127.0.0.1:3000/products \
-H "content-type: application/json" \
-d '{"name":"Notebook","price_cents":450}'HTTP/1.1 200 OK
content-type: application/json
{"id":1,"name":"Notebook","price_cents":450}From here on, outputs leave out the content-length header. It is the byte count of the body.
Two common mistakes with JSON input:
A field is missing. The handler is not called. Axum answers 422 Unprocessable Entity. The status means the JSON is valid but does not fit NewProduct.
curl -i -X POST http://127.0.0.1:3000/products \
-H "content-type: application/json" \
-d '{"name":"Pen"}'HTTP/1.1 422 Unprocessable Entity
content-type: text/plain; charset=utf-8
Failed to deserialize the JSON body into the target type: missing field `price_cents` at line 1 column 14The content-type header is missing. Json only accepts requests that declare content-type: application/json. Axum answers 415 Unsupported Media Type.
curl -i -X POST http://127.0.0.1:3000/products -d '{"name":"Pen","price_cents":150}'HTTP/1.1 415 Unsupported Media Type
content-type: text/plain; charset=utf-8
Expected request headers with `Content-Type: application/json`Example 5: Status codes and 404
A handler can return a status code together with the body. Added: StatusCode, a tuple return, and Result to return an error status.
src/main.rs
use axum::{
extract::Path,
http::StatusCode,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct Product {
id: u32,
name: String,
price_cents: u32,
}
#[derive(Deserialize)]
struct NewProduct {
name: String,
price_cents: u32,
}
async fn create_product(Json(input): Json<NewProduct>) -> (StatusCode, Json<Product>) {
let product = Product {
id: 1,
name: input.name,
price_cents: input.price_cents,
};
(StatusCode::CREATED, Json(product))
}
async fn get_product(Path(id): Path<u32>) -> Result<Json<Product>, StatusCode> {
if id == 1 {
Ok(Json(Product {
id: 1,
name: "Pen".to_string(),
price_cents: 150,
}))
} else {
Err(StatusCode::NOT_FOUND)
}
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/products", axum::routing::post(create_product))
.route("/products/{id}", get(get_product));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}What each new part does:
StatusCodeis the type for status codes.StatusCode::CREATEDis201,StatusCode::NOT_FOUNDis404.(StatusCode, Json<Product>)is a tuple. Axum sends the first item as the status and the second as the body.201 Createdis the code for "a new item was made".Result<Json<Product>, StatusCode>lets a handler succeed with JSON or fail with only a status code.Ok(...)is sent as a normal response.Err(...)is sent as the error response.- Only product
1exists until Example 6 adds real storage.
curl -i -X POST http://127.0.0.1:3000/products \
-H "content-type: application/json" \
-d '{"name":"Pen","price_cents":150}'HTTP/1.1 201 Created
content-type: application/json
{"id":1,"name":"Pen","price_cents":150}curl -i http://127.0.0.1:3000/products/99HTTP/1.1 404 Not Found
content-length: 0The body is empty because the handler returned only a status code.
Example 6: Shared state
Products must survive between requests, so the handlers need one shared store. Added: a Store struct, Arc<Mutex<...>>, State, and with_state.
- State is a value that Axum keeps and gives to every handler that asks for it.
Mutexallows one handler at a time to use the value inside it. Requests run at the same time, so unguarded shared data could be corrupted.lock()waits for the turn and returns a guard. The value is released when the guard goes out of scope.Arclets many owners hold the same value. Axum clones the state for each request, and cloning anArccopies only a pointer to the shared value.
src/main.rs
use std::sync::{Arc, Mutex};
use axum::{
extract::{Path, State},
http::StatusCode,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize)]
struct Product {
id: u32,
name: String,
price_cents: u32,
}
#[derive(Deserialize)]
struct NewProduct {
name: String,
price_cents: u32,
}
struct Store {
products: Vec<Product>,
next_id: u32,
}
type Shared = Arc<Mutex<Store>>;
async fn list_products(State(store): State<Shared>) -> Json<Vec<Product>> {
let store = store.lock().unwrap();
Json(store.products.clone())
}
async fn create_product(
State(store): State<Shared>,
Json(input): Json<NewProduct>,
) -> (StatusCode, Json<Product>) {
let mut store = store.lock().unwrap();
let product = Product {
id: store.next_id,
name: input.name,
price_cents: input.price_cents,
};
store.next_id += 1;
store.products.push(product.clone());
(StatusCode::CREATED, Json(product))
}
async fn get_product(
State(store): State<Shared>,
Path(id): Path<u32>,
) -> Result<Json<Product>, StatusCode> {
let store = store.lock().unwrap();
match store.products.iter().find(|p| p.id == id) {
Some(product) => Ok(Json(product.clone())),
None => Err(StatusCode::NOT_FOUND),
}
}
#[tokio::main]
async fn main() {
let store: Shared = Arc::new(Mutex::new(Store {
products: Vec::new(),
next_id: 1,
}));
let app = Router::new()
.route("/products", get(list_products).post(create_product))
.route("/products/{id}", get(get_product))
.with_state(store);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}What each new part does:
type Shared = Arc<Mutex<Store>>;gives the long type a short name.State(store): State<Shared>is the extractor that hands a handler the shared value. It is an argument, likePath..with_state(store)gives the value to the router. If it is missing, the program does not compile, because the handlers ask for state the router does not have.store.lock().unwrap()takes the lock.unwraphandles the case where another handler panicked while holding it.let store = store.lock().unwrap();reuses the namestore. The newstoreis the guard, and it hides theArcfor the rest of the function.- The standard library
Mutexis safe here because the handlers never call.awaitwhile holding the guard. Holding it across an.awaitneeds a different approach. Jsonmust be the last argument of a handler. It reads the request body, and the body can be read only once.
Create two products, then list them.
curl -s -X POST http://127.0.0.1:3000/products \
-H "content-type: application/json" -d '{"name":"Pen","price_cents":150}'
curl -s -X POST http://127.0.0.1:3000/products \
-H "content-type: application/json" -d '{"name":"Notebook","price_cents":450}'
curl -i http://127.0.0.1:3000/products{"id":1,"name":"Pen","price_cents":150}{"id":2,"name":"Notebook","price_cents":450}HTTP/1.1 200 OK
content-type: application/json
[{"id":1,"name":"Pen","price_cents":150},{"id":2,"name":"Notebook","price_cents":450}]-s hides curl's progress output. The two -s calls print no trailing newline, so their bodies run together with the third response's first line.
The data lives in the program's memory. Stopping the process empties the store.
Example 7: Splitting routes into their own router
With many routes, main gets long. A router can be built in a function and attached under a path prefix with nest. Added: routes() and .nest("/products", ...).
This example moves the product code into a second file. mod products; in main.rs tells Rust to load src/products.rs. pub marks the items that main.rs may use.
src/products.rs
use std::sync::{Arc, Mutex};
use axum::{
extract::{Path, State},
http::StatusCode,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize)]
pub struct Product {
id: u32,
name: String,
price_cents: u32,
}
#[derive(Deserialize)]
struct NewProduct {
name: String,
price_cents: u32,
}
pub struct Store {
products: Vec<Product>,
next_id: u32,
}
impl Store {
pub fn new() -> Self {
Store {
products: Vec::new(),
next_id: 1,
}
}
}
pub type Shared = Arc<Mutex<Store>>;
pub fn routes() -> Router<Shared> {
Router::new()
.route("/", get(list_products).post(create_product))
.route("/{id}", get(get_product))
}
async fn list_products(State(store): State<Shared>) -> Json<Vec<Product>> {
let store = store.lock().unwrap();
Json(store.products.clone())
}
async fn create_product(
State(store): State<Shared>,
Json(input): Json<NewProduct>,
) -> (StatusCode, Json<Product>) {
let mut store = store.lock().unwrap();
let product = Product {
id: store.next_id,
name: input.name,
price_cents: input.price_cents,
};
store.next_id += 1;
store.products.push(product.clone());
(StatusCode::CREATED, Json(product))
}
async fn get_product(
State(store): State<Shared>,
Path(id): Path<u32>,
) -> Result<Json<Product>, StatusCode> {
let store = store.lock().unwrap();
match store.products.iter().find(|p| p.id == id) {
Some(product) => Ok(Json(product.clone())),
None => Err(StatusCode::NOT_FOUND),
}
}src/main.rs
mod products;
use std::sync::{Arc, Mutex};
use axum::{routing::get, Router};
async fn health() -> &'static str {
"ok"
}
#[tokio::main]
async fn main() {
let store: products::Shared = Arc::new(Mutex::new(products::Store::new()));
let app = Router::new()
.route("/health", get(health))
.nest("/products", products::routes())
.with_state(store);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}What each new part does:
pub fn routes() -> Router<Shared>returns a router that has routes but no state yet.Router<Shared>means "a router that still needs aSharedvalue".- Inside it, the paths are relative:
/and/{id}. .nest("/products", products::routes())puts the prefix/productsin front of them.GET /productsandGET /products/7work as before..with_state(store)is called once on the outer router. It supplies the state to the nested routes as well.
Example 8: One layer that logs requests
A layer wraps the router. Every request passes through it before the handler, and every response passes through it on the way out. Code written as a layer for logging, timing, or login checks is called middleware. Added: log_request, and .layer(...).
src/main.rs (changed parts)
use axum::{
extract::Request,
middleware::{self, Next},
response::Response,
};
async fn log_request(request: Request, next: Next) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_string();
let response = next.run(request).await;
println!("{} {} -> {}", method, path, response.status().as_u16());
response
}In main, add the layer after the routes:
let app = Router::new()
.route("/health", get(health))
.nest("/products", products::routes())
.layer(middleware::from_fn(log_request))
.with_state(store);What each new part does:
middleware::from_fn(log_request)turns anasync fninto a layer. The function takes the incomingRequestand aNextvalue.request.method().clone()andrequest.uri().path().to_string()copy the method and path out of the request, becausenext.run(request)takes the request away.next.run(request).awaitpasses the request to the rest of the app (the router and the handler) and waits for itsResponse.println!runs after the handler finished, so the status code is known.- Returning
responsesends it on to the client unchanged. .layer(...)applies to the routes added before it. Routes added after.layerare not wrapped.
Each request now prints one line in the terminal that runs cargo run:
GET /health -> 200
POST /products -> 201
GET /products/1 -> 200
GET /products/99 -> 404How a request flows
For GET /products/2:
curlopens a connection to127.0.0.1:3000and sends the request.axum::serveaccepts the connection and passes the request to the router.- The
log_requestlayer receives the request first and callsnext.run. - The router removes the
/productsprefix, matches/{id}withGET, and recordsid = 2. - Axum runs the extractors of
get_productin order:Stateclones theArc, andPath<u32>converts2to a number. get_productlocks the store, finds the product, and returnsOk(Json(product)).- Axum converts the return value into a response: status
200, headercontent-type: application/json, and the JSON body. - The response goes back through
log_request, which printsGET /products/2 -> 200. axum::servewrites the response to the connection.
If an extractor fails in step 5 (for example abc instead of a number), step 6 is skipped and the extractor's error response is sent instead. The layer still logs it.
Complete example
This is the full products service with create, list, read, replace, and delete. It adds PUT and DELETE handlers, and a max_price query filter on the list, using only the pieces from Examples 1 to 8.
File tree:
products-api/
Cargo.toml
src/
main.rs
products.rsCargo.toml
[package]
name = "products-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "net"] }src/products.rs
use std::sync::{Arc, Mutex};
use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize)]
pub struct Product {
id: u32,
name: String,
price_cents: u32,
}
#[derive(Deserialize)]
struct NewProduct {
name: String,
price_cents: u32,
}
#[derive(Deserialize)]
struct ListParams {
max_price: Option<u32>,
}
pub struct Store {
products: Vec<Product>,
next_id: u32,
}
impl Store {
pub fn new() -> Self {
Store {
products: Vec::new(),
next_id: 1,
}
}
}
pub type Shared = Arc<Mutex<Store>>;
pub fn routes() -> Router<Shared> {
Router::new()
.route("/", get(list_products).post(create_product))
.route(
"/{id}",
get(get_product).put(replace_product).delete(delete_product),
)
}
async fn list_products(
State(store): State<Shared>,
Query(params): Query<ListParams>,
) -> Json<Vec<Product>> {
let store = store.lock().unwrap();
let max = params.max_price.unwrap_or(u32::MAX);
let found = store
.products
.iter()
.filter(|p| p.price_cents <= max)
.cloned()
.collect();
Json(found)
}
async fn create_product(
State(store): State<Shared>,
Json(input): Json<NewProduct>,
) -> (StatusCode, Json<Product>) {
let mut store = store.lock().unwrap();
let product = Product {
id: store.next_id,
name: input.name,
price_cents: input.price_cents,
};
store.next_id += 1;
store.products.push(product.clone());
(StatusCode::CREATED, Json(product))
}
async fn get_product(
State(store): State<Shared>,
Path(id): Path<u32>,
) -> Result<Json<Product>, StatusCode> {
let store = store.lock().unwrap();
match store.products.iter().find(|p| p.id == id) {
Some(product) => Ok(Json(product.clone())),
None => Err(StatusCode::NOT_FOUND),
}
}
async fn replace_product(
State(store): State<Shared>,
Path(id): Path<u32>,
Json(input): Json<NewProduct>,
) -> Result<Json<Product>, StatusCode> {
let mut store = store.lock().unwrap();
match store.products.iter_mut().find(|p| p.id == id) {
Some(product) => {
product.name = input.name;
product.price_cents = input.price_cents;
Ok(Json(product.clone()))
}
None => Err(StatusCode::NOT_FOUND),
}
}
async fn delete_product(State(store): State<Shared>, Path(id): Path<u32>) -> StatusCode {
let mut store = store.lock().unwrap();
let before = store.products.len();
store.products.retain(|p| p.id != id);
if store.products.len() < before {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}src/main.rs
mod products;
use std::sync::{Arc, Mutex};
use axum::{
extract::Request,
middleware::{self, Next},
response::Response,
routing::get,
Router,
};
async fn health() -> &'static str {
"ok"
}
async fn log_request(request: Request, next: Next) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_string();
let response = next.run(request).await;
println!("{} {} -> {}", method, path, response.status().as_u16());
response
}
#[tokio::main]
async fn main() {
let store: products::Shared = Arc::new(Mutex::new(products::Store::new()));
let app = Router::new()
.route("/health", get(health))
.nest("/products", products::routes())
.layer(middleware::from_fn(log_request))
.with_state(store);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on 127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}Run it:
cargo runThen send these requests from a second terminal, in order. Each response is shown under its request. The content-length and date headers are left out.
Create two products:
curl -i -X POST http://127.0.0.1:3000/products \
-H "content-type: application/json" -d '{"name":"Pen","price_cents":150}'HTTP/1.1 201 Created
content-type: application/json
{"id":1,"name":"Pen","price_cents":150}curl -i -X POST http://127.0.0.1:3000/products \
-H "content-type: application/json" -d '{"name":"Notebook","price_cents":450}'HTTP/1.1 201 Created
content-type: application/json
{"id":2,"name":"Notebook","price_cents":450}List all, then only products at or below 200 cents:
curl -i http://127.0.0.1:3000/productsHTTP/1.1 200 OK
content-type: application/json
[{"id":1,"name":"Pen","price_cents":150},{"id":2,"name":"Notebook","price_cents":450}]curl -i "http://127.0.0.1:3000/products?max_price=200"HTTP/1.1 200 OK
content-type: application/json
[{"id":1,"name":"Pen","price_cents":150}]Read one product, and read one that does not exist:
curl -i http://127.0.0.1:3000/products/2HTTP/1.1 200 OK
content-type: application/json
{"id":2,"name":"Notebook","price_cents":450}curl -i http://127.0.0.1:3000/products/99HTTP/1.1 404 Not Found
content-length: 0Replace product 1:
curl -i -X PUT http://127.0.0.1:3000/products/1 \
-H "content-type: application/json" -d '{"name":"Pen","price_cents":175}'HTTP/1.1 200 OK
content-type: application/json
{"id":1,"name":"Pen","price_cents":175}Delete product 2, then list what is left:
curl -i -X DELETE http://127.0.0.1:3000/products/2HTTP/1.1 204 No Contentcurl -i http://127.0.0.1:3000/productsHTTP/1.1 200 OK
content-type: application/json
[{"id":1,"name":"Pen","price_cents":175}]Send a body with a missing field:
curl -i -X POST http://127.0.0.1:3000/products \
-H "content-type: application/json" -d '{"name":"Pen"}'HTTP/1.1 422 Unprocessable Entity
content-type: text/plain; charset=utf-8
Failed to deserialize the JSON body into the target type: missing field `price_cents` at line 1 column 14The terminal that runs the server printed:
listening on 127.0.0.1:3000
POST /products -> 201
POST /products -> 201
GET /products -> 200
GET /products -> 200
GET /products/2 -> 200
GET /products/99 -> 404
PUT /products/1 -> 200
DELETE /products/2 -> 204
GET /products -> 200
POST /products -> 422Summary
- A handler is an
async fn. ARoutermaps a method and path to a handler. axum::serve(listener, app)runs the router on a TokioTcpListener.- Axum 0.8 writes path parameters as
{id}.Path,Query,Json, andStateare extractors. Json<T>as an argument reads a JSON body (needsDeserialize). As a return type, it writes JSON (needsSerialize).- A handler returns a status code alone, a
(StatusCode, body)tuple, or aResultwith a status code as the error. - An unknown path gives
404. A known path with a wrong method gives405. A body that does not match its type gives422, and a missing JSON content type gives415. - Shared state is
Arc<Mutex<T>>, given to the router withwith_stateand read withState<T>. nestmounts a router under a path prefix..layer(middleware::from_fn(f))wraps the routes added before it.
