#[cfg(feature = "http")]
use axum::{
body::Body,
extract::Request,
http::{HeaderValue, StatusCode, header::CONTENT_TYPE},
middleware::Next,
response::Response,
};
#[cfg(feature = "http")]
const REJECTION_PREFIXES: [&str; 3] = [
"Failed to deserialize the JSON body into the target type:",
"Failed to parse the request body as JSON:",
"Expected request with `Content-Type",
];
#[cfg(feature = "http")]
fn extract_field(detail: &str) -> Option<String> {
if let Some(pos) = detail.find("missing field `") {
let rest = &detail[pos + "missing field `".len()..];
if let Some(end) = rest.find('`') {
return Some(rest[..end].to_string());
}
}
if let Some(pos) = detail.find(": invalid type") {
let head = detail[..pos].trim();
let head = head.trim_start_matches('`').trim_end_matches('`');
let field = head.rsplit('.').next().unwrap_or(head);
if !field.is_empty()
&& field
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
{
return Some(field.to_string());
}
}
None
}
#[cfg(feature = "http")]
fn normalize_rejection_body(text: &str) -> Option<serde_json::Value> {
let matched = REJECTION_PREFIXES
.iter()
.find(|p| text.trim_start().starts_with(**p))?;
let detail = text.trim_start()[matched.len()..].trim();
let message = if detail.is_empty() {
matched.to_string()
} else {
detail.to_string()
};
let field = extract_field(&message);
Some(serde_json::json!({
"type": "InvalidInput",
"message": message,
"field": field,
"value": serde_json::Value::Null,
}))
}
#[cfg(feature = "http")]
pub async fn rejection_normalizer(req: Request, next: Next) -> Response {
let res = next.run(req).await;
let candidate = matches!(
res.status(),
StatusCode::BAD_REQUEST
| StatusCode::UNSUPPORTED_MEDIA_TYPE
| StatusCode::UNPROCESSABLE_ENTITY
) && res
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|v| v.starts_with("text/plain"))
.unwrap_or(false);
if !candidate {
return res;
}
let (mut parts, body) = res.into_parts();
let bytes = match axum::body::to_bytes(body, 16 * 1024).await {
Ok(b) => b,
Err(_) => {
return Response::builder()
.status(parts.status)
.body(Body::empty())
.unwrap_or_else(|_| Response::new(Body::empty()));
}
};
let text = String::from_utf8_lossy(&bytes);
match normalize_rejection_body(&text) {
Some(payload) => {
let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
parts.headers.insert(
CONTENT_TYPE,
"application/json"
.parse()
.unwrap_or(HeaderValue::from_static("application/json")),
);
Response::from_parts(parts, Body::from(json))
}
None => Response::from_parts(parts, Body::from(bytes)),
}
}
#[cfg(all(test, feature = "http"))]
mod tests {
use super::*;
#[test]
fn missing_field_yields_field_name() {
let out = normalize_rejection_body(
"Failed to deserialize the JSON body into the target type: missing field `text` at line 1 column 40",
)
.unwrap();
assert_eq!(out["type"], "InvalidInput");
assert_eq!(out["field"], "text");
assert!(out["message"].as_str().unwrap().contains("missing field"));
}
#[test]
fn invalid_type_yields_field_name() {
let out = normalize_rejection_body(
"Failed to deserialize the JSON body into the target type: text: invalid type: integer `12345`, expected a string at line 1 column 34",
)
.unwrap();
assert_eq!(out["field"], "text");
}
#[test]
fn syntax_error_keeps_message_without_field() {
let out = normalize_rejection_body(
"Failed to parse the request body as JSON: key must be a string at line 1 column 2",
)
.unwrap();
assert_eq!(out["type"], "InvalidInput");
assert!(out["field"].is_null());
assert_eq!(out["value"], serde_json::Value::Null);
}
#[test]
fn content_type_rejection_normalized() {
let out = normalize_rejection_body(
"Expected request with `Content-Type: application/json` but the request was missing it",
)
.unwrap();
assert_eq!(out["type"], "InvalidInput");
}
#[test]
fn unrecognized_text_passes_through() {
assert!(normalize_rejection_body("some other error").is_none());
}
#[test]
fn serde_path_dotted_field_takes_last_segment() {
let out = normalize_rejection_body(
"Failed to deserialize the JSON body into the target type: queries[1].query: invalid type: null, expected a string",
)
.unwrap();
assert_eq!(out["field"], "query");
}
}