← 中文资源

简体中文版本

Rust Axum JSON 处理器与参数校验

在 Axum 0.8 中组合路径、查询与共享状态提取器,返回类型化 JSON,并显式映射 400 与 404 错误。

阅读英文原文

功能说明

示例构建一个只读商品接口:GET /items 支持 limit 与 offset,GET /items/{id} 返回单个商品。商品存放在 Arc 中,通过 State 注入两个处理器。查询 limit 必须在 1 到 50 之间,否则返回 400;路径 id 不存在时返回 404。错误响应统一为 JSON 对象,成功响应由 Json 提取器自动设置 Content-Type。

关键代码

路由使用 Axum 0.8 的 {id} 路径语法,旧版本写作 :id,升级时需同步修改。ApiError 实现 IntoResponse,把枚举映射为状态码和错误体,处理器因此可以返回 Result<Json<_>, ApiError> 并使用问号风格。list_items 先克隆值再按 id 排序,保证分页顺序稳定;skip 与 take 实现偏移分页。State 类型是 Arc,克隆成本低且可跨请求共享。

使用注意

示例把数据放在内存中,重启即丢失;生产环境应换成数据库或并发安全的缓存,并考虑分页快照一致性。offset 分页在数据量大时性能下降,且并发插入会导致重复或遗漏,可改用游标。limit 有上限但 offset 未限制,仍可能扫描过多记录。错误信息不应泄露内部细节。服务未配置 CORS、超时与优雅停机,需要按部署环境补充。

验证方式

在 Cargo.toml 中启用 axum 0.8、tokio 全特性、serde 派生与 serde_json,运行后执行 curl http://127.0.0.1:3000/items?limit=1 应得到包含一个商品的数组;请求 /items/99 返回 404 与 error 字段;limit=0 或 limit=51 返回 400。还可用 curl -i 检查状态码与 application/json 响应头。

代码示例

use std::collections::HashMap;
use std::sync::Arc;

use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::get,
    Json, Router,
};
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize)]
struct Item {
    id: u32,
    name: String,
    price_cents: u32,
}

#[derive(Debug, Deserialize)]
struct ListQuery {
    limit: Option<usize>,
    offset: Option<usize>,
}

#[derive(Debug, Serialize)]
struct ErrorBody {
    error: String,
}

#[derive(Debug)]
enum ApiError {
    NotFound(u32),
    BadRequest(String),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::NotFound(id) => (StatusCode::NOT_FOUND, format!("item {id} not found")),
            ApiError::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
        };
        (status, Json(ErrorBody { error: message })).into_response()
    }
}

type AppState = Arc<HashMap<u32, Item>>;

async fn get_item(
    State(items): State<AppState>,
    Path(id): Path<u32>,
) -> Result<Json<Item>, ApiError> {
    items
        .get(&id)
        .cloned()
        .map(Json)
        .ok_or(ApiError::NotFound(id))
}

async fn list_items(
    State(items): State<AppState>,
    Query(query): Query<ListQuery>,
) -> Result<Json<Vec<Item>>, ApiError> {
    let limit = query.limit.unwrap_or(10);
    if !(1..=50).contains(&limit) {
        return Err(ApiError::BadRequest("limit must be between 1 and 50".to_string()));
    }
    let offset = query.offset.unwrap_or(0);

    let mut all: Vec<Item> = items.values().cloned().collect();
    all.sort_by_key(|item| item.id);
    Ok(Json(all.into_iter().skip(offset).take(limit).collect()))
}

#[tokio::main]
async fn main() {
    let mut catalog = HashMap::new();
    catalog.insert(1, Item { id: 1, name: "Keyboard".to_string(), price_cents: 4_900 });
    catalog.insert(2, Item { id: 2, name: "Mouse".to_string(), price_cents: 2_500 });

    let app = Router::new()
        .route("/items", get(list_items))
        .route("/items/{id}", get(get_item))
        .with_state(Arc::new(catalog));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    println!("listening on http://{}", listener.local_addr().unwrap());
    axum::serve(listener, app).await.unwrap();
}