Lesson 4 of 30
REST API Design: Resources, Validation, and Pagination
REST (Representational State Transfer) is a style for HTTP APIs in which URLs name resources and HTTP methods express actions on them. This lesson builds the Storefront products API in four steps: resources and status codes, DTOs with validation, cursor pagination, and versioning with an OpenAPI document. It uses the router and extractors from Axum routing, state, and handlers. Error bodies are refined in the next lesson.
Project setup
Create the crate with cargo new storefront-rest.
Cargo.toml
[package]
name = "storefront-rest"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
base64 = "0.22"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "net"] }
utoipa = "5"Example 1: Resources, methods, and status codes
A URL names a resource, and the method names the action. Collections use plural nouns. An item is addressed by its identifier.
| Request | Meaning | Success status |
|---|---|---|
GET /products |
List products | 200 |
POST /products |
Create a product | 201 with a Location header |
GET /products/{id} |
Read one product | 200 |
PUT /products/{id} |
Replace a product | 200 or 204 |
PATCH /products/{id} |
Change some fields | 200 or 204 |
DELETE /products/{id} |
Remove a product | 204 |
POST /orders/{id}/cancel |
An action that is not a field update | 200 or 202 |
GETis safe: it must not change state. Caches and proxies rely on that.GET,PUT, andDELETEare idempotent: repeating the call leaves the same server state. A secondDELETEmay return 404.POSTis not idempotent. Two identicalPOST /orderscalls create two orders unless the service uses an idempotency key (see SQL transactions).- Status codes: 200 success with body, 201 created, 202 accepted for later, 204 no body, 400 malformed request, 401 not authenticated, 403 not allowed, 404 not found, 409 conflict with current state, 422 well-formed but invalid, 429 too many requests.
This first version has create, read, and delete with an in-memory store.
src/main.rs
use std::sync::{Arc, Mutex};
use axum::{
extract::{Path, State},
http::{header, StatusCode},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize)]
struct Product {
id: i64,
name: String,
price_cents: u32,
}
#[derive(Deserialize)]
struct CreateProduct {
name: String,
price_cents: u32,
}
#[derive(Default)]
struct Store {
next_id: i64,
products: Vec<Product>,
}
#[derive(Clone, Default)]
struct AppState {
store: Arc<Mutex<Store>>,
}
async fn create(
State(state): State<AppState>,
Json(input): Json<CreateProduct>,
) -> impl IntoResponse {
let mut store = state.store.lock().unwrap();
store.next_id += 1;
let product = Product {
id: store.next_id,
name: input.name,
price_cents: input.price_cents,
};
store.products.push(product.clone());
let location = format!("/products/{}", product.id);
(StatusCode::CREATED, [(header::LOCATION, location)], Json(product))
}
async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Product>, StatusCode> {
let store = state.store.lock().unwrap();
let found = store.products.iter().find(|p| p.id == id).cloned();
found.map(Json).ok_or(StatusCode::NOT_FOUND)
}
async fn remove(State(state): State<AppState>, Path(id): Path<i64>) -> StatusCode {
let mut store = state.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
}
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/products", post(create))
.route("/products/{id}", get(get_one).delete(remove))
.with_state(AppState::default());
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}The array [(header::LOCATION, location)] is itself IntoResponse and adds a header when it appears in the response tuple. The base64 and utoipa dependencies in Cargo.toml are used from Example 2 on.
curl -s -w '\n%{http_code} %header{location}\n' -X POST localhost:3000/products \
-H 'content-type: application/json' -d '{"name":"Notebook","price_cents":450}'
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:3000/products/1
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:3000/products/1{"id":1,"name":"Notebook","price_cents":450}
201 /products/1
204
404Example 2: DTOs, validation, and error bodies
A DTO (data transfer object) is a type that defines the shape of a request or response body. A database row and an API body change for different reasons. A row has internal columns such as a cost price or a soft-delete flag. Separate types keep those columns out of responses.
Deserialization checks types. Validation checks rules a type cannot express. A request that is well-formed but breaks a rule gets 422 and a list of failed fields. The code is now split into files. The row type lives in state.rs, and ProductRow stands in for the table the SQLx lesson adds.
src/state.rs
use std::sync::{Arc, Mutex};
pub struct ProductRow {
pub id: i64,
pub name: String,
pub price_cents: i32,
pub cost_cents: i32,
pub deleted: bool,
pub created_at: i64,
}
#[derive(Default)]
pub struct Store {
pub next_id: i64,
pub rows: Vec<ProductRow>,
}
#[derive(Clone, Default)]
pub struct AppState {
pub store: Arc<Mutex<Store>>,
}src/error.rs
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct FieldError {
pub field: &'static str,
pub message: String,
}
impl FieldError {
pub fn new(field: &'static str, message: impl Into<String>) -> Self {
FieldError { field, message: message.into() }
}
}
#[derive(Serialize)]
struct ErrorBody {
errors: Vec<FieldError>,
}
pub enum ApiError {
NotFound,
Validation(Vec<FieldError>),
BadCursor,
}
fn respond(status: StatusCode, errors: Vec<FieldError>) -> Response {
(status, Json(ErrorBody { errors })).into_response()
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
match self {
ApiError::NotFound => respond(
StatusCode::NOT_FOUND,
vec![FieldError::new("id", "product not found")],
),
ApiError::Validation(errors) => respond(StatusCode::UNPROCESSABLE_ENTITY, errors),
ApiError::BadCursor => respond(
StatusCode::BAD_REQUEST,
vec![FieldError::new("cursor", "invalid cursor")],
),
}
}
}src/model.rs
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::error::FieldError;
use crate::state::ProductRow;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreateProduct {
pub name: String,
pub price_cents: u32,
}
impl CreateProduct {
pub fn validate(&self) -> Result<(), Vec<FieldError>> {
let mut errors = Vec::new();
let name = self.name.trim();
if name.is_empty() {
errors.push(FieldError::new("name", "must not be empty"));
} else if name.chars().count() > 100 {
errors.push(FieldError::new("name", "must be at most 100 characters"));
}
if self.price_cents == 0 || self.price_cents > 100_000_000 {
errors.push(FieldError::new("price_cents", "must be between 1 and 100000000"));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
// ToSchema is used by the OpenAPI document in Example 4.
#[derive(Serialize, ToSchema)]
pub struct ProductDto {
pub id: i64,
pub name: String,
pub price_cents: u32,
}
impl From<&ProductRow> for ProductDto {
fn from(row: &ProductRow) -> Self {
ProductDto {
id: row.id,
name: row.name.clone(),
price_cents: row.price_cents as u32,
}
}
}src/write.rs
use std::time::{SystemTime, UNIX_EPOCH};
use axum::{
extract::{Path, State},
http::{header, StatusCode},
response::IntoResponse,
Json,
};
use crate::error::ApiError;
use crate::model::{CreateProduct, ProductDto};
use crate::state::{AppState, ProductRow};
pub async fn create(
State(state): State<AppState>,
Json(input): Json<CreateProduct>,
) -> Result<impl IntoResponse, ApiError> {
input.validate().map_err(ApiError::Validation)?;
let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let mut store = state.store.lock().unwrap();
store.next_id += 1;
let row = ProductRow {
id: store.next_id,
name: input.name.trim().to_owned(),
price_cents: input.price_cents as i32,
cost_cents: 0,
deleted: false,
created_at,
};
let dto = ProductDto::from(&row);
let location = format!("/api/v1/products/{}", row.id);
store.rows.push(row);
Ok((StatusCode::CREATED, [(header::LOCATION, location)], Json(dto)))
}
pub async fn remove(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
let mut store = state.store.lock().unwrap();
let row = store
.rows
.iter_mut()
.find(|r| r.id == id && !r.deleted)
.ok_or(ApiError::NotFound)?;
row.deleted = true;
Ok(StatusCode::NO_CONTENT)
}src/read.rs
use axum::{
extract::{Path, State},
Json,
};
use crate::error::ApiError;
use crate::model::ProductDto;
use crate::state::AppState;
pub async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<ProductDto>, ApiError> {
let store = state.store.lock().unwrap();
let row = store
.rows
.iter()
.find(|r| r.id == id && !r.deleted)
.ok_or(ApiError::NotFound)?;
Ok(Json(ProductDto::from(row)))
}src/main.rs
mod error;
mod model;
mod read;
mod state;
mod write;
use axum::{
routing::{get, post},
Router,
};
use state::AppState;
fn app(state: AppState) -> Router {
let v1 = Router::new()
.route("/products", post(write::create))
.route("/products/{id}", get(read::get_one).delete(write::remove));
Router::new().nest("/api/v1", v1).with_state(state)
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app(AppState::default())).await.unwrap();
}#[serde(deny_unknown_fields)]fails deserialization when the body has a field the type does not declare. A misspelledprice_centsis an error instead of being ignored. The failure is a 422 from theJsonextractor.CreateProducthas noid. The server assigns identifiers.u32rejects negative numbers during deserialization. The row usesi32because PostgreSQL has no unsigned integer type, so the conversion happens increateand inFrom.validatecollects every failure, so a client fixes all problems in one request. It useschars().count()becauselen()counts bytes.- A rule that needs the database, such as a unique SKU, is a constraint. The handler maps the violation to 409.
- A plain
Option<T>in a PATCH body cannot tell a missing field from an explicitnull. Ifnullmust clear a field, the DTO needs a three-state type. - Money is stored as integer cents. Binary floating point cannot represent most decimal amounts exactly.
ApiErroris a small version of the service-wide error type built in the next lesson. TheBadCursorvariant is used in Example 3.
Example 3: Cursor pagination
A list endpoint returns one page at a time. Offset pagination takes limit and offset and runs SELECT ... ORDER BY id LIMIT $1 OFFSET $2. It has two limits: the database reads and discards offset rows, so deep pages are slow, and inserts or deletes between requests shift the pages, so a client can see an item twice or miss one.
Cursor pagination resumes after the last item the client saw. The query filters on the sort key instead of skipping rows. The sort order must be unique, so id is the tie-breaker after created_at. The SQL form of the query, for the tables of the SQLx lesson, is:
CREATE INDEX products_created_at_id_idx ON products (created_at DESC, id DESC);
SELECT id, name, price_cents, created_at
FROM products
WHERE ($1::timestamptz IS NULL OR (created_at, id) < ($1::timestamptz, $2::bigint))
ORDER BY created_at DESC, id DESC
LIMIT $3;This lesson has no database, so list applies the same rule to the in-memory rows. This step replaces src/read.rs and changes one route in src/main.rs. src/state.rs, src/error.rs, src/model.rs, and src/write.rs are unchanged.
src/read.rs
use axum::{
extract::{Path, Query, State},
Json,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::error::ApiError;
use crate::model::ProductDto;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct PageParams {
pub limit: Option<u32>,
pub cursor: Option<String>,
}
// ToSchema is used by the OpenAPI document in Example 4.
#[derive(Serialize, ToSchema)]
pub struct ProductPage {
pub items: Vec<ProductDto>,
pub next_cursor: Option<String>,
}
fn encode_cursor(created_at: i64, id: i64) -> String {
URL_SAFE_NO_PAD.encode(format!("{created_at}:{id}"))
}
fn decode_cursor(cursor: &str) -> Result<(i64, i64), ApiError> {
let bytes = URL_SAFE_NO_PAD.decode(cursor).map_err(|_| ApiError::BadCursor)?;
let text = String::from_utf8(bytes).map_err(|_| ApiError::BadCursor)?;
let (created_at, id) = text.split_once(':').ok_or(ApiError::BadCursor)?;
let created_at = created_at.parse().map_err(|_| ApiError::BadCursor)?;
let id = id.parse().map_err(|_| ApiError::BadCursor)?;
Ok((created_at, id))
}
pub async fn list(
State(state): State<AppState>,
Query(params): Query<PageParams>,
) -> Result<Json<ProductPage>, ApiError> {
let limit = params.limit.unwrap_or(20).clamp(1, 100) as usize;
let after = params.cursor.as_deref().map(decode_cursor).transpose()?;
let store = state.store.lock().unwrap();
let mut rows: Vec<_> = store.rows.iter().filter(|r| !r.deleted).collect();
rows.sort_by(|a, b| (b.created_at, b.id).cmp(&(a.created_at, a.id)));
// Fetch one extra row to learn whether another page exists.
let mut rows: Vec<_> = rows
.into_iter()
.filter(|r| match after {
Some(key) => (r.created_at, r.id) < key,
None => true,
})
.take(limit + 1)
.collect();
let next_cursor = if rows.len() > limit {
rows.truncate(limit);
rows.last().map(|r| encode_cursor(r.created_at, r.id))
} else {
None
};
let items = rows.into_iter().map(ProductDto::from).collect();
Ok(Json(ProductPage { items, next_cursor }))
}
pub async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<ProductDto>, ApiError> {
let store = state.store.lock().unwrap();
let row = store
.rows
.iter()
.find(|r| r.id == id && !r.deleted)
.ok_or(ApiError::NotFound)?;
Ok(Json(ProductDto::from(row)))
}src/main.rs
mod error;
mod model;
mod read;
mod state;
mod write;
use axum::{
routing::{get, post},
Router,
};
use state::AppState;
fn app(state: AppState) -> Router {
let v1 = Router::new()
.route("/products", post(write::create).get(read::list))
.route("/products/{id}", get(read::get_one).delete(write::remove));
Router::new().nest("/api/v1", v1).with_state(state)
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app(AppState::default())).await.unwrap();
}- The server caps
limitbetween 1 and 100, so a client cannot load the whole table. - The handler fetches
limit + 1rows. If more thanlimitcome back, another page exists. This avoids a count query. - The cursor is the base64 form of
created_at:id. It is opaque to the client, so the server can change the encoding. - A cursor that fails to decode returns 400. A cursor is not signed, so the server treats it as untrusted input and uses it only as a comparison key.
- Cursor pagination gives no total count and no jump to page N.
Example 4: Versioning and OpenAPI
A version lets the API change without breaking clients. Adding an optional response field or a new endpoint is compatible. Removing or renaming a field, changing a type, or tightening validation is breaking.
| Strategy | Example | Consequence |
|---|---|---|
| Path | /api/v1/products |
Visible in logs and caches. Maps to a nested router |
| Header | Accept: application/vnd.storefront.v2+json |
Clean URLs. Harder to try in a browser |
| Query | /products?version=2 |
Optional for clients. Often avoided for caching |
This lesson uses path versioning. v1_routes returns one router, and nest mounts it. A second version is another router mounted at /api/v2, with shared code below both.
OpenAPI is a JSON or YAML document that lists endpoints, parameters, bodies, and responses. Tools generate documentation, clients, and contract tests from it. The utoipa crate builds the document from Rust types. ToSchema describes a type, #[utoipa::path] describes a handler, and #[derive(OpenApi)] collects them.
This step adds src/api.rs, changes src/main.rs, and adds one #[utoipa::path] attribute above each of list and get_one in src/read.rs. Every other line of src/read.rs stays as in Example 3. The full annotated file is in the complete example below. The attribute above list is:
#[utoipa::path(
get,
path = "/api/v1/products",
params(
("limit" = Option<u32>, Query, description = "Page size, 1 to 100, default 20"),
("cursor" = Option<String>, Query, description = "Cursor from a previous page")
),
responses(
(status = 200, description = "One page of products", body = ProductPage),
(status = 400, description = "Invalid cursor")
)
)]The attribute above get_one is:
#[utoipa::path(
get,
path = "/api/v1/products/{id}",
params(("id" = i64, Path, description = "Product id")),
responses(
(status = 200, description = "The product", body = ProductDto),
(status = 404, description = "No such product")
)
)]src/api.rs
use utoipa::OpenApi;
use crate::model::ProductDto;
use crate::read::{self, ProductPage};
#[derive(OpenApi)]
#[openapi(
paths(read::list, read::get_one),
components(schemas(ProductDto, ProductPage))
)]
pub struct ApiDoc;src/main.rs
mod api;
mod error;
mod model;
mod read;
mod state;
mod write;
use axum::{
routing::{get, post},
Json, Router,
};
use utoipa::OpenApi;
use api::ApiDoc;
use state::AppState;
async fn openapi() -> Json<utoipa::openapi::OpenApi> {
Json(ApiDoc::openapi())
}
fn v1_routes() -> Router<AppState> {
Router::new()
.route("/products", post(write::create).get(read::list))
.route("/products/{id}", get(read::get_one).delete(write::remove))
.route("/openapi.json", get(openapi))
}
fn app(state: AppState) -> Router {
Router::new().nest("/api/v1", v1_routes()).with_state(state)
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app(AppState::default())).await.unwrap();
}- The annotations are written by hand next to the handlers, so they can drift from the code. A contract test that compares the served document with a stored copy catches accidental changes.
- Each live version adds maintenance work. A deprecation policy states how long the old version is supported. A
DeprecationorSunsetresponse header lets clients detect the schedule. - Conventions that apply to every endpoint: one field naming style, one list envelope, RFC 3339 UTC timestamps, and no change of meaning for an existing field within a version.
Complete example
File tree:
storefront-rest/
Cargo.toml
src/
main.rs
api.rs
error.rs
model.rs
read.rs
state.rs
write.rsCargo.toml
[package]
name = "storefront-rest"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
base64 = "0.22"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "net"] }
utoipa = "5"src/state.rs
use std::sync::{Arc, Mutex};
pub struct ProductRow {
pub id: i64,
pub name: String,
pub price_cents: i32,
pub cost_cents: i32,
pub deleted: bool,
pub created_at: i64,
}
#[derive(Default)]
pub struct Store {
pub next_id: i64,
pub rows: Vec<ProductRow>,
}
#[derive(Clone, Default)]
pub struct AppState {
pub store: Arc<Mutex<Store>>,
}src/error.rs
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct FieldError {
pub field: &'static str,
pub message: String,
}
impl FieldError {
pub fn new(field: &'static str, message: impl Into<String>) -> Self {
FieldError { field, message: message.into() }
}
}
#[derive(Serialize)]
struct ErrorBody {
errors: Vec<FieldError>,
}
pub enum ApiError {
NotFound,
Validation(Vec<FieldError>),
BadCursor,
}
fn respond(status: StatusCode, errors: Vec<FieldError>) -> Response {
(status, Json(ErrorBody { errors })).into_response()
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
match self {
ApiError::NotFound => respond(
StatusCode::NOT_FOUND,
vec![FieldError::new("id", "product not found")],
),
ApiError::Validation(errors) => respond(StatusCode::UNPROCESSABLE_ENTITY, errors),
ApiError::BadCursor => respond(
StatusCode::BAD_REQUEST,
vec![FieldError::new("cursor", "invalid cursor")],
),
}
}
}src/model.rs
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::error::FieldError;
use crate::state::ProductRow;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreateProduct {
pub name: String,
pub price_cents: u32,
}
impl CreateProduct {
pub fn validate(&self) -> Result<(), Vec<FieldError>> {
let mut errors = Vec::new();
let name = self.name.trim();
if name.is_empty() {
errors.push(FieldError::new("name", "must not be empty"));
} else if name.chars().count() > 100 {
errors.push(FieldError::new("name", "must be at most 100 characters"));
}
if self.price_cents == 0 || self.price_cents > 100_000_000 {
errors.push(FieldError::new("price_cents", "must be between 1 and 100000000"));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
// ToSchema is used by the OpenAPI document in Example 4.
#[derive(Serialize, ToSchema)]
pub struct ProductDto {
pub id: i64,
pub name: String,
pub price_cents: u32,
}
impl From<&ProductRow> for ProductDto {
fn from(row: &ProductRow) -> Self {
ProductDto {
id: row.id,
name: row.name.clone(),
price_cents: row.price_cents as u32,
}
}
}src/write.rs
use std::time::{SystemTime, UNIX_EPOCH};
use axum::{
extract::{Path, State},
http::{header, StatusCode},
response::IntoResponse,
Json,
};
use crate::error::ApiError;
use crate::model::{CreateProduct, ProductDto};
use crate::state::{AppState, ProductRow};
pub async fn create(
State(state): State<AppState>,
Json(input): Json<CreateProduct>,
) -> Result<impl IntoResponse, ApiError> {
input.validate().map_err(ApiError::Validation)?;
let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let mut store = state.store.lock().unwrap();
store.next_id += 1;
let row = ProductRow {
id: store.next_id,
name: input.name.trim().to_owned(),
price_cents: input.price_cents as i32,
cost_cents: 0,
deleted: false,
created_at,
};
let dto = ProductDto::from(&row);
let location = format!("/api/v1/products/{}", row.id);
store.rows.push(row);
Ok((StatusCode::CREATED, [(header::LOCATION, location)], Json(dto)))
}
pub async fn remove(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
let mut store = state.store.lock().unwrap();
let row = store
.rows
.iter_mut()
.find(|r| r.id == id && !r.deleted)
.ok_or(ApiError::NotFound)?;
row.deleted = true;
Ok(StatusCode::NO_CONTENT)
}src/read.rs
use axum::{
extract::{Path, Query, State},
Json,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::error::ApiError;
use crate::model::ProductDto;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct PageParams {
pub limit: Option<u32>,
pub cursor: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ProductPage {
pub items: Vec<ProductDto>,
pub next_cursor: Option<String>,
}
fn encode_cursor(created_at: i64, id: i64) -> String {
URL_SAFE_NO_PAD.encode(format!("{created_at}:{id}"))
}
fn decode_cursor(cursor: &str) -> Result<(i64, i64), ApiError> {
let bytes = URL_SAFE_NO_PAD.decode(cursor).map_err(|_| ApiError::BadCursor)?;
let text = String::from_utf8(bytes).map_err(|_| ApiError::BadCursor)?;
let (created_at, id) = text.split_once(':').ok_or(ApiError::BadCursor)?;
let created_at = created_at.parse().map_err(|_| ApiError::BadCursor)?;
let id = id.parse().map_err(|_| ApiError::BadCursor)?;
Ok((created_at, id))
}
#[utoipa::path(
get,
path = "/api/v1/products",
params(
("limit" = Option<u32>, Query, description = "Page size, 1 to 100, default 20"),
("cursor" = Option<String>, Query, description = "Cursor from a previous page")
),
responses(
(status = 200, description = "One page of products", body = ProductPage),
(status = 400, description = "Invalid cursor")
)
)]
pub async fn list(
State(state): State<AppState>,
Query(params): Query<PageParams>,
) -> Result<Json<ProductPage>, ApiError> {
let limit = params.limit.unwrap_or(20).clamp(1, 100) as usize;
let after = params.cursor.as_deref().map(decode_cursor).transpose()?;
let store = state.store.lock().unwrap();
let mut rows: Vec<_> = store.rows.iter().filter(|r| !r.deleted).collect();
rows.sort_by(|a, b| (b.created_at, b.id).cmp(&(a.created_at, a.id)));
// Fetch one extra row to learn whether another page exists.
let mut rows: Vec<_> = rows
.into_iter()
.filter(|r| match after {
Some(key) => (r.created_at, r.id) < key,
None => true,
})
.take(limit + 1)
.collect();
let next_cursor = if rows.len() > limit {
rows.truncate(limit);
rows.last().map(|r| encode_cursor(r.created_at, r.id))
} else {
None
};
let items = rows.into_iter().map(ProductDto::from).collect();
Ok(Json(ProductPage { items, next_cursor }))
}
#[utoipa::path(
get,
path = "/api/v1/products/{id}",
params(("id" = i64, Path, description = "Product id")),
responses(
(status = 200, description = "The product", body = ProductDto),
(status = 404, description = "No such product")
)
)]
pub async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<ProductDto>, ApiError> {
let store = state.store.lock().unwrap();
let row = store
.rows
.iter()
.find(|r| r.id == id && !r.deleted)
.ok_or(ApiError::NotFound)?;
Ok(Json(ProductDto::from(row)))
}src/api.rs
use utoipa::OpenApi;
use crate::model::ProductDto;
use crate::read::{self, ProductPage};
#[derive(OpenApi)]
#[openapi(
paths(read::list, read::get_one),
components(schemas(ProductDto, ProductPage))
)]
pub struct ApiDoc;src/main.rs
mod api;
mod error;
mod model;
mod read;
mod state;
mod write;
use axum::{
routing::{get, post},
Json, Router,
};
use utoipa::OpenApi;
use api::ApiDoc;
use state::AppState;
async fn openapi() -> Json<utoipa::openapi::OpenApi> {
Json(ApiDoc::openapi())
}
fn v1_routes() -> Router<AppState> {
Router::new()
.route("/products", post(write::create).get(read::list))
.route("/products/{id}", get(read::get_one).delete(write::remove))
.route("/openapi.json", get(openapi))
}
fn app(state: AppState) -> Router {
Router::new().nest("/api/v1", v1_routes()).with_state(state)
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app(AppState::default())).await.unwrap();
}Run
cargo runTry it
Create a product. The response carries the status and the Location header. The %header{} write-out variable needs curl 7.84 or later.
curl -s -w '\n%{http_code} %header{location}\n' -X POST localhost:3000/api/v1/products \
-H 'content-type: application/json' -d '{"name":"Notebook","price_cents":450}'{"id":1,"name":"Notebook","price_cents":450}
201 /api/v1/products/1Create two more products.
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:3000/api/v1/products \
-H 'content-type: application/json' -d '{"name":"Pen","price_cents":120}'
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:3000/api/v1/products \
-H 'content-type: application/json' -d '{"name":"Ruler","price_cents":300}'201
201An invalid body returns 422 with one entry per failed field. An unknown field returns 422 as well.
curl -s -w '\n%{http_code}\n' -X POST localhost:3000/api/v1/products \
-H 'content-type: application/json' -d '{"name":" ","price_cents":0}'
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:3000/api/v1/products \
-H 'content-type: application/json' -d '{"name":"Ink","price":5}'{"errors":[{"field":"name","message":"must not be empty"},{"field":"price_cents","message":"must be between 1 and 100000000"}]}
422
422Read one product, then request a missing one.
curl -s localhost:3000/api/v1/products/1
curl -s -w '\n%{http_code}\n' localhost:3000/api/v1/products/99{"id":1,"name":"Notebook","price_cents":450}
{"errors":[{"field":"id","message":"product not found"}]}
404Page through the list, two items at a time, newest first. The first page returns products 3 and 2 and a next_cursor. The cursor encodes the creation time, so its value differs between runs. The output below shows <opaque cursor> in its place. The shell variable carries the real value to the second request.
curl -s 'localhost:3000/api/v1/products?limit=2'
CURSOR=$(curl -s 'localhost:3000/api/v1/products?limit=2' | sed -n 's/.*"next_cursor":"\([^"]*\)".*/\1/p')
curl -s "localhost:3000/api/v1/products?limit=2&cursor=$CURSOR"
curl -s -o /dev/null -w '%{http_code}\n' 'localhost:3000/api/v1/products?cursor=!!!'{"items":[{"id":3,"name":"Ruler","price_cents":300},{"id":2,"name":"Pen","price_cents":120}],"next_cursor":"<opaque cursor>"}
{"items":[{"id":1,"name":"Notebook","price_cents":450}],"next_cursor":null}
400Delete a product. A repeated delete returns 404.
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:3000/api/v1/products/3
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:3000/api/v1/products/3204
404Read the OpenAPI document and list its paths.
curl -s localhost:3000/api/v1/openapi.json | grep -o '"/api/v1/products[^"]*"'"/api/v1/products"
"/api/v1/products/{id}"Summary
- URLs name resources with plural nouns, and methods name actions.
GETis safe.GET,PUT, andDELETEare idempotent.POSTis not. - Status codes carry the outcome: 201 with
Locationon create, 204 on delete, 404, 409, and 422 for the failure cases. - Request, response, and row types are separate. Row types carry internal columns. DTOs define the public contract.
deny_unknown_fieldsrejects unexpected input fields. Unsigned types reject negative numbers.- Validation runs once at the boundary, collects all field errors, and returns 422. Rules that need the database are constraints, mapped to 409.
- Offset pagination costs more on deep pages and shifts under concurrent writes. Cursor pagination filters on a unique sort key, uses an index, and stays stable.
- The server caps the page size. Fetching
limit + 1rows detects a next page. - Additive changes are compatible. Removing fields, renaming fields, or tightening validation is breaking.
- Money is stored as integer cents.
- An OpenAPI document generated with
utoipadescribes the contract and supports contract tests.
