use axum::Json;
use axum::body::Body;
use axum::extract::{FromRequest, FromRequestParts};
use axum::http::header::HeaderMap;
use axum::http::request::Parts;
use axum::http::{Request, header};
use serde::de::DeserializeOwned;
use tibba_error::Error;
use validator::Validate;
fn map_err(err: impl ToString, sub_category: &str) -> Error {
map_err_with_status(err, sub_category, 400)
}
fn map_err_with_status(err: impl ToString, sub_category: &str, status: u16) -> Error {
Error::new(err)
.with_category("params")
.with_sub_category(sub_category)
.with_status(status)
}
fn json_rejection(err: axum::extract::rejection::JsonRejection) -> Error {
let status = err.status().as_u16();
let status = if (400..500).contains(&status) {
status
} else {
400
};
map_err_with_status(err.body_text(), "from_json", status)
}
#[derive(Debug, Clone, Copy, Default)]
pub struct JsonParams<T>(pub T);
impl<T, S> FromRequest<S> for JsonParams<T>
where
T: DeserializeOwned + Validate,
S: Send + Sync,
{
type Rejection = Error;
async fn from_request(req: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
if json_content_type(req.headers()) {
let Json(value) = Json::<T>::from_request(req, state)
.await
.map_err(json_rejection)?;
value.validate().map_err(|e| map_err(e, "validate"))?;
Ok(JsonParams(value))
} else {
Err(map_err_with_status(
"expected request with `Content-Type: application/json`",
"from_json",
415,
))
}
}
}
fn json_content_type(headers: &HeaderMap) -> bool {
let Some(value) = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
else {
return false;
};
let Ok(mime) = value.parse::<mime::Mime>() else {
return false;
};
mime.type_() == mime::APPLICATION
&& (mime.subtype() == mime::JSON || mime.suffix() == Some(mime::JSON))
}
#[derive(Debug, Clone, Copy, Default)]
pub struct QueryParams<T>(pub T);
impl<T, S> FromRequestParts<S> for QueryParams<T>
where
T: DeserializeOwned + Validate,
S: Send + Sync,
{
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let query = parts.uri.query().unwrap_or_default();
let params: T =
serde_urlencoded::from_str(query).map_err(|err| map_err(err, "from_query"))?;
params.validate().map_err(|e| map_err(e, "validate"))?;
Ok(QueryParams(params))
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{HeaderValue, StatusCode};
use axum::response::IntoResponse;
use pretty_assertions::assert_eq;
use serde::Deserialize;
#[derive(Debug, Deserialize, Validate)]
struct Payload {
#[validate(email)]
email: String,
}
fn json_request(body: &'static str) -> Request<Body> {
Request::builder()
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body))
.expect("构造测试请求")
}
async fn reject(req: Request<Body>) -> (StatusCode, serde_json::Value) {
let err = JsonParams::<Payload>::from_request(req, &())
.await
.expect_err("本用例应当被拒绝");
let res = err.into_response();
let status = res.status();
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.expect("读取响应体");
(
status,
serde_json::from_slice(&bytes).expect("响应体应是合法 JSON"),
)
}
#[tokio::test]
async fn validation_failure_is_client_error_with_reason() {
let (status, body) = reject(json_request(r#"{"email":"not-an-email"}"#)).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["category"], "params");
assert_eq!(body["sub_category"], "validate");
assert_ne!(
body["message"], "internal server error",
"校验失败的原因不得被脱敏掉"
);
}
#[tokio::test]
async fn malformed_json_is_client_error() {
let (status, body) = reject(json_request("{not json")).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["sub_category"], "from_json");
}
#[tokio::test]
async fn missing_content_type_is_unsupported_media_type() {
let req = Request::builder()
.body(Body::from(r#"{"email":"a@b.com"}"#))
.expect("构造测试请求");
let (status, body) = reject(req).await;
assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
assert_eq!(body["sub_category"], "from_json");
}
#[tokio::test]
async fn type_mismatch_is_unprocessable_entity() {
let (status, body) = reject(json_request(r#"{"email":123}"#)).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(body["sub_category"], "from_json");
}
#[tokio::test]
async fn oversized_body_is_payload_too_large() {
use axum::extract::DefaultBodyLimit;
use axum::routing::post;
use tower::ServiceExt;
async fn handler(JsonParams(_p): JsonParams<Payload>) -> &'static str {
"ok"
}
let app = axum::Router::new()
.route("/", post(handler))
.layer(DefaultBodyLimit::max(64));
let big = format!(r#"{{"email":"{}@b.com"}}"#, "a".repeat(256));
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(big))
.expect("构造测试请求"),
)
.await
.expect("请求应得到响应");
assert_eq!(res.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[test]
fn json_content_type_accepts_json_family_only() {
let check = |value: &str| {
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_str(value).expect("合法头部值"),
);
json_content_type(&headers)
};
assert!(check("application/json"));
assert!(check("application/json; charset=utf-8"));
assert!(check("APPLICATION/JSON"));
assert!(check("application/merge-patch+json"));
assert!(!check("application/jsonx"));
assert!(!check("text/plain; fmt=application/json"));
assert!(!check("text/json"));
assert!(!check("application/x-www-form-urlencoded"));
assert!(!check("not a mime"));
assert!(!json_content_type(&HeaderMap::new()));
}
}