Guide contents

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

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
  • GET is safe: it must not change state. Caches and proxies rely on that.
  • GET, PUT, and DELETE are idempotent: repeating the call leaves the same server state. A second DELETE may return 404.
  • POST is not idempotent. Two identical POST /orders calls 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

Rust
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.

Terminal
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
Output
{"id":1,"name":"Notebook","price_cents":450}
201 /products/1
204
404

Example 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

Rust
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

Rust
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

Rust
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

Rust
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

Rust
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

Rust
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 misspelled price_cents is an error instead of being ignored. The failure is a 422 from the Json extractor.
  • CreateProduct has no id. The server assigns identifiers.
  • u32 rejects negative numbers during deserialization. The row uses i32 because PostgreSQL has no unsigned integer type, so the conversion happens in create and in From.
  • validate collects every failure, so a client fixes all problems in one request. It uses chars().count() because len() 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 explicit null. If null must 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.
  • ApiError is a small version of the service-wide error type built in the next lesson. The BadCursor variant 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:

SQL
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

Rust
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

Rust
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 limit between 1 and 100, so a client cannot load the whole table.
  • The handler fetches limit + 1 rows. If more than limit come 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:

Rust
#[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:

Rust
#[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

Rust
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

Rust
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 Deprecation or Sunset response 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:

Text
storefront-rest/
  Cargo.toml
  src/
    main.rs
    api.rs
    error.rs
    model.rs
    read.rs
    state.rs
    write.rs

Cargo.toml

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

Rust
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

Rust
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

Rust
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

Rust
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

Rust
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

Rust
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

Rust
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

Terminal
cargo run

Try it

Create a product. The response carries the status and the Location header. The %header{} write-out variable needs curl 7.84 or later.

Terminal
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}'
Output
{"id":1,"name":"Notebook","price_cents":450}
201 /api/v1/products/1

Create two more products.

Terminal
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}'
Output
201
201

An invalid body returns 422 with one entry per failed field. An unknown field returns 422 as well.

Terminal
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}'
Output
{"errors":[{"field":"name","message":"must not be empty"},{"field":"price_cents","message":"must be between 1 and 100000000"}]}
422
422

Read one product, then request a missing one.

Terminal
curl -s localhost:3000/api/v1/products/1
curl -s -w '\n%{http_code}\n' localhost:3000/api/v1/products/99
Output
{"id":1,"name":"Notebook","price_cents":450}
{"errors":[{"field":"id","message":"product not found"}]}
404

Page 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.

Terminal
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=!!!'
Output
{"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}
400

Delete a product. A repeated delete returns 404.

Terminal
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/3
Output
204
404

Read the OpenAPI document and list its paths.

Terminal
curl -s localhost:3000/api/v1/openapi.json | grep -o '"/api/v1/products[^"]*"'
Output
"/api/v1/products"
"/api/v1/products/{id}"

Summary

  • URLs name resources with plural nouns, and methods name actions. GET is safe. GET, PUT, and DELETE are idempotent. POST is not.
  • Status codes carry the outcome: 201 with Location on 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_fields rejects 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 + 1 rows 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 utoipa describes the contract and supports contract tests.