Rust in Everything
基于 axum 0.8.9

Expected request with Content-Type: application/json 与 400 / 422

主线位置:请求主线的末段,Handler::call 跑最后一个参数 Json<T>::from_request。提取失败就把 rejection 变成响应直接返回,你的函数不会被调用。

flowchart TB
  req["Request"] --> ct{"Content-Type 是 JSON?"}
  ct -->|否| r415["415:不是 JSON 类型"]
  ct -->|是| buf["读 body"]
  buf -->|超上限| r413["413:超上限"]
  buf --> de["反序列化"]
  de -->|"Syntax、Eof"| r400["400:语法错"]
  de -->|Data| r422["422:数据不符"]
  de --> ok["调用 handler"]

Json 的判断顺序、+json 后缀、400 和 422 的分界、错误里的字段路径,在 body 提取器 里用 oneshot 逐条试过。这一页看真实客户端:同一个接口,客户端怎么发会得到哪一种,以及怎样让这些拒绝对前端更友好。

复现:真实的客户端发了什么

起一个真正的 axum::serve,用 curl 8.7.1 和 Node v22.23.3 的 fetch 各发几次,服务端用一个 from_fn 中间件打印收到的 Content-Type:

axum = "=0.8.9"
serde = { version = "1", features = ["derive"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread", "net"] }
use std::process::Command;

use axum::{
  Json, Router,
  extract::Request,
  middleware::{self, Next},
  response::Response,
  routing::post,
};
use serde::Deserialize;

#[derive(Deserialize)]
struct NewUser {
  name: String,
}

async fn create(Json(user): Json<NewUser>) -> String {
  format!("created {}", user.name)
}

// print what the client actually sent before the extractor sees it
async fn show_content_type(req: Request, next: Next) -> Response {
  let ct = req.headers().get("content-type").map(|v| v.to_str().unwrap().to_owned());
  println!("  server saw content-type: {ct:?}");
  next.run(req).await
}

fn run(label: &str, program: &str, args: &[&str]) {
  println!("{label}");
  let out = Command::new(program).args(args).output().unwrap();
  println!("  client got: {}", String::from_utf8_lossy(&out.stdout).trim_end());
}

#[tokio::main]
async fn main() {
  let app = Router::new()
    .route("/users", post(create))
    .layer(middleware::from_fn(show_content_type));
  let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
  let url = format!("http://{}/users", listener.local_addr().unwrap());
  tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });

  // the server runs on the worker threads; the clients block this one
  let curl = |label: &str, extra: &[&str]| {
    let mut args = vec!["-s", "--noproxy", "*", "-w", " %{http_code}"];
    args.extend_from_slice(extra);
    args.push(&url);
    run(label, "curl", &args);
  };
  curl("curl -d '{\"name\":\"ann\"}'", &["-d", r#"{"name":"ann"}"#]);
  curl("curl --json '{\"name\":\"ann\"}'", &["--json", r#"{"name":"ann"}"#]);
  curl("curl -X POST -H 'content-type: application/json'", &["-X", "POST", "-H", "content-type: application/json"]);
  curl("curl --json '{\"name\":1}'", &["--json", r#"{"name":1}"#]);
  let js = format!(
    "fetch('{url}', {{ method: 'POST', body: JSON.stringify({{ name: 'ann' }}) }})\
     .then(async r => console.log(await r.text(), r.status))"
  );
  run("fetch(url, { method: 'POST', body: JSON.stringify(...) })", "node", &["-e", &js]);
}

M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同。每段第二行是服务端看到的,第三行是客户端拿到的正文和状态码:

curl -d '{"name":"ann"}'
  server saw content-type: Some("application/x-www-form-urlencoded")
  client got: Expected request with `Content-Type: application/json` 415
curl --json '{"name":"ann"}'
  server saw content-type: Some("application/json")
  client got: created ann 200
curl -X POST -H 'content-type: application/json'
  server saw content-type: Some("application/json")
  client got: Failed to parse the request body as JSON: EOF while parsing a value at line 1 column 0 400
curl --json '{"name":1}'
  server saw content-type: Some("application/json")
  client got: Failed to deserialize the JSON body into the target type: name: invalid type: integer `1`, expected a string at line 1 column 9 422
fetch(url, { method: 'POST', body: JSON.stringify(...) })
  server saw content-type: Some("text/plain;charset=UTF-8")
  client got: Expected request with `Content-Type: application/json` 415
  • curl -d:body 确实是 JSON,但 curl 给 -d 配的类型是 application/x-www-form-urlencoded。Json 只看头不看内容,415。用 --json(它同时设了 Content-Type 和 Accept)或者显式 -H 'content-type: application/json'。
  • 有类型、没有 body:Content-Type 对了,body 是空的。空串不是 JSON,serde_json 在第 1 行第 0 列就遇到了结尾,归类为 Eof,400。给「body 可选」的接口发空请求,碰到的就是这一行。
  • 字段类型不对:JSON 本身没错,name 要字符串给了数字,422,正文里带着出错的字段路径 name。
  • fetch 只传字符串 body:按 Fetch 规范,字符串 body 默认类型是 text/plain;charset=UTF-8,JSON.stringify 不会改变这一点。要写 headers: { 'content-type': 'application/json' }。

前端最常见的两种 415,问题都在客户端,不在 handler。

源码:四种拒绝

Json::from_request 先查头、再读 body、最后反序列化:

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        if !json_content_type(req.headers()) {
            return Err(MissingJsonContentType.into());
        }

        let bytes = Bytes::from_request(req, state).await?;
        Self::from_bytes(&bytes)
    }

它的 Rejection 是 JsonRejection,一个四选一的枚举。前三个的状态码和正文前半句在 rejection.rs 里用 define_rejection! 写死:

变体状态码正文什么时候
MissingJsonContentType415Expected request with `Content-Type: application/json` 没有这个头,或者不是 application/json、application/*+json
JsonSyntaxError400Failed to parse the request body as JSON: + serde_json 的错误Syntax、Eof:不是合法 JSON、提前结束、末尾还有别的字符
JsonDataError422Failed to deserialize the JSON body into the target type: + 路径和错误Data:合法 JSON,但缺字段、类型不对、数值越界
BytesRejection413 或 400Failed to buffer the request body: ...读 body 时出错,主要是超过上限,见 Failed to buffer the request body

MissingJsonContentType 这个名字有点误导:头存在、只是值不对(比如上面的 text/plain;charset=UTF-8),也是它。

__define_rejection! 给每个拒绝生成 status()、body_text() 和 IntoResponse,后者是 (status, body_text).into_response(),所以响应总是 text/plain; charset=utf-8。生成响应之前它还调一次 __log_rejection!:开着 tracing feature(默认开)时,往 target axum::rejection 发一条 TRACE 级别的事件,带状态码、正文和拒绝类型。级别是 TRACE,常见的 RUST_LOG=info 下这些 4xx 在服务端日志里一行也没有。axum 的提取器文档(「Logging rejections」一节)给的开法是 RUST_LOG=info,axum::rejection=trace cargo run。

修法:服务端怎么回

客户端的修法上面已经说了。服务端能做的,是让拒绝以前端容易处理的形式出现。默认的纯文本正文对 fetch(...).then(r => r.json()) 不友好,可以把 Json 包一层,换掉它的 Rejection。axum-macros 的 #[derive(FromRequest)] 支持 via 和 rejection 两个参数,axum 仓库的 examples/customize-extractor-error 用的就是这个写法:

axum = { version = "=0.8.9", features = ["macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }
tower = { version = "=0.5.3", features = ["util"] }
http-body-util = "0.1"
use axum::{
  Router,
  body::Body,
  extract::{FromRequest, rejection::JsonRejection},
  http::{Request, StatusCode},
  response::{IntoResponse, Response},
  routing::post,
};
use http_body_util::BodyExt;
use serde::Deserialize;
use tower::ServiceExt;

// `axum::Json` underneath, our own rejection on top
#[derive(FromRequest)]
#[from_request(via(axum::Json), rejection(ApiError))]
struct Json<T>(T);

struct ApiError {
  status: StatusCode,
  message: String,
}

impl From<JsonRejection> for ApiError {
  fn from(rejection: JsonRejection) -> Self {
    Self { status: rejection.status(), message: rejection.body_text() }
  }
}

impl IntoResponse for ApiError {
  fn into_response(self) -> Response {
    let body = serde_json::json!({ "error": self.message });
    (self.status, axum::Json(body)).into_response()
  }
}

#[derive(Deserialize)]
struct NewUser {
  name: String,
}

async fn create(Json(user): Json<NewUser>) -> String {
  format!("created {}", user.name)
}

#[tokio::main]
async fn main() {
  let app = Router::new().route("/users", post(create));
  let cases: [(Option<&str>, &str); 4] = [
    (Some("application/json"), r#"{"name":"ann"}"#),
    (Some("text/plain"), r#"{"name":"ann"}"#),
    (Some("application/json"), r#"{"name":"#),
    (Some("application/json"), r#"{"name":1}"#),
  ];
  for (ct, body) in cases {
    let mut req = Request::post("/users");
    if let Some(ct) = ct {
      req = req.header("content-type", ct);
    }
    let res = app.clone().oneshot(req.body(Body::from(body)).unwrap()).await.unwrap();
    let status = res.status();
    let ctype = res.headers()["content-type"].to_str().unwrap().to_owned();
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
    println!("{status} [{ctype}] {}", String::from_utf8_lossy(&bytes));
  }
}

连跑 3 次输出相同:

200 OK [text/plain; charset=utf-8] created ann
415 Unsupported Media Type [application/json] {"error":"Expected request with `Content-Type: application/json`"}
400 Bad Request [application/json] {"error":"Failed to parse the request body as JSON: name: EOF while parsing a value at line 1 column 8"}
422 Unprocessable Entity [application/json] {"error":"Failed to deserialize the JSON body into the target type: name: invalid type: integer `1`, expected a string at line 1 column 9"}
  • 状态码没变:rejection.status() 原样带过来,415 / 400 / 422 的语义保留。
  • 正文变成 application/json,前端统一按 JSON 解析。
  • handler 只是把参数类型从 axum::Json 换成了自己的 Json,其余不动。想在 ApiError 里对不同变体做不同处理(比如 422 时把字段路径单独拿出来),match rejection { JsonRejection::JsonDataError(e) => ..., ... } 即可,JsonRejection 是 #[non_exhaustive],要留一个 _ 分支。

只在个别 handler 里处理,也可以直接把参数写成 Result<Json<T>, JsonRejection>,拒绝作为值交给函数(FromRequestParts 与 FromRequest 的「包一层」)。

❗IMPORTANT
动脑筋:Option<Json<T>> 收到一个没有 Content-Type 的 JSON body,会怎样?
axum = "=0.8.9"
serde = { version = "1", features = ["derive"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }
tower = { version = "=0.5.3", features = ["util"] }
http-body-util = "0.1"
use axum::{Json, Router, body::Body, http::Request, routing::post};
use http_body_util::BodyExt;
use serde::Deserialize;
use tower::ServiceExt;

#[derive(Deserialize)]
struct Patch {
  name: String,
}

async fn update(patch: Option<Json<Patch>>) -> String {
  match patch {
    Some(Json(p)) => format!("rename to {}", p.name),
    None => "nothing to change".to_owned(),
  }
}

#[tokio::main]
async fn main() {
  let app = Router::new().route("/me", post(update));
  let cases: [(&str, Option<&str>, &str); 4] = [
    ("json", Some("application/json"), r#"{"name":"ann"}"#),
    ("no content-type", None, r#"{"name":"ann"}"#),
    ("text/plain", Some("text/plain"), r#"{"name":"ann"}"#),
    ("json, empty body", Some("application/json"), ""),
  ];
  for (label, ct, body) in cases {
    let mut req = Request::post("/me");
    if let Some(ct) = ct {
      req = req.header("content-type", ct);
    }
    let res = app.clone().oneshot(req.body(Body::from(body)).unwrap()).await.unwrap();
    let status = res.status();
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
    println!("{label:<17} {status} {}", String::from_utf8_lossy(&bytes));
  }
}

四个请求的 body 前三个相同,只有头不同;第四个头对了、body 是空的。哪几个会走到 None 分支?答案在本页末尾。

Option<Json<T>> 能编译,本身就是 0.8 里后补的。

📝NOTE
冷知识:Option<Json<T>> 是为「空 body」加的,判断的却是有没有头

0.8.0 换成 OptionalFromRequest 之后,Json 起初没有实现它,Option<Json<T>> 编译不过。#3142「Implement OptionalFromRequest for the Json extractor」补上了这个实现,PR 的动机是:「I want to receive requests with an empty body when using the Json extractor without the request getting rejected.」

合进去的 OptionalFromRequest for Json<T> 看的却不是 body,而是 Content-Type 头:没有这个头就是 None,有头但不是 JSON 是 415,有 JSON 头就照常读 body、照常报错。所以「空 body」只有在客户端同时不发 Content-Type 时才变成 None;带着 application/json 发一个空 body,结果和不用 Option 一样是 400。

提交 ef0b99b6 第一次出现在 tag axum-v0.8.2(那个 tag 的 CHANGELOG 把它记在 0.8.1 下,仓库里没有 0.8.1 的 tag;到 0.8.9 的 CHANGELOG 里它被列在 0.8.3 下)。

动脑筋答案

M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同:

json              200 OK rename to ann
no content-type   200 OK nothing to change
text/plain        415 Unsupported Media Type Expected request with `Content-Type: application/json`
json, empty body  400 Bad Request Failed to parse the request body as JSON: EOF while parsing a value at line 1 column 0

只有第二个是 None。

  • no content-type:body 里明明有完整的 JSON,handler 却拿到 None,回了「nothing to change」,状态码 200。客户端忘了设头,请求被当成「没有 body」,修改被静默丢掉,没有任何报错。用 Option<Json<T>> 的接口,这比 415 更难发现。
  • text/plain:有头但不是 JSON,Option 不吞这个错误,照样 415。
  • json, empty body:头是 JSON,就要求 body 是 JSON,空串是 Eof,400。

想让「空 body 等于不传」,就别用 Option<Json<T>>:用 Bytes 拿到原始字节,空的时候走默认值,否则再 Json::<T>::from_bytes。

小结

  • Json<T> 先看 Content-Type(application/json 或 +json),不看 body 内容:curl -d 和只传字符串的 fetch 都会得到 415。
  • JSON 本身写错(含空 body)是 400,结构对不上是 422,正文里有 serde_path_to_error 给的字段路径。
  • 拒绝的正文是 text/plain,只在 axum::rejection target 的 TRACE 级别记日志;默认日志配置下服务端看不到这些 4xx。
  • 用 #[derive(FromRequest)] 加 via(axum::Json)、rejection(ApiError) 包一层,可以保留状态码、换成 JSON 错误体。
  • Option<Json<T>> 按「有没有 Content-Type 头」判断 None,不按 body 是否为空;没带头的 JSON 会被静默当成 None。

💬 关联讨论

发起讨论
加载中...