use std::ops::Deref;
use axum::extract::FromRequest;
use axum::extract::rejection::JsonRejection;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::de::DeserializeOwned;
use crate::fallback::json_error_response;
pub struct JsonBody<T>(pub T);
impl<T> Deref for JsonBody<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
impl<T> std::fmt::Debug for JsonBody<T>
where
T: std::fmt::Debug,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("JsonBody").field(&self.0).finish()
}
}
impl<T> Clone for JsonBody<T>
where
T: Clone,
{
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
#[derive(Debug)]
pub enum JsonBodyRejection {
UnsupportedMediaType,
InvalidJson(String),
PayloadTooLarge,
}
impl IntoResponse for JsonBodyRejection {
fn into_response(self) -> Response {
let (status, message) = match self {
Self::UnsupportedMediaType => (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"Content-Type must be application/json".to_string(),
),
Self::InvalidJson(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg),
Self::PayloadTooLarge => (
StatusCode::PAYLOAD_TOO_LARGE,
"request body too large".to_string(),
),
};
json_error_response(status, message)
}
}
fn has_json_content_type(headers: &HeaderMap) -> bool {
headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| {
let mime = v.split(';').next().unwrap_or("").trim();
mime.eq_ignore_ascii_case("application/json")
})
}
impl<S, T> FromRequest<S> for JsonBody<T>
where
S: Send + Sync,
T: DeserializeOwned,
{
type Rejection = JsonBodyRejection;
async fn from_request(req: axum::extract::Request, state: &S) -> Result<Self, Self::Rejection> {
if !has_json_content_type(req.headers()) {
return Err(JsonBodyRejection::UnsupportedMediaType);
}
match axum::Json::<T>::from_request(req, state).await {
Ok(axum::Json(value)) => Ok(Self(value)),
Err(JsonRejection::BytesRejection(e)) => {
if e.status() == StatusCode::PAYLOAD_TOO_LARGE {
Err(JsonBodyRejection::PayloadTooLarge)
} else {
Err(JsonBodyRejection::InvalidJson(format!(
"failed to read body: {e}"
)))
}
}
Err(rejection) => Err(JsonBodyRejection::InvalidJson(rejection_message(rejection))),
}
}
}
fn rejection_message(rejection: JsonRejection) -> String {
match rejection {
JsonRejection::JsonDataError(e) => format!("invalid JSON: {e}"),
JsonRejection::JsonSyntaxError(e) => format!("malformed JSON: {e}"),
JsonRejection::MissingJsonContentType(_) => {
"Content-Type must be application/json".to_string()
}
JsonRejection::BytesRejection(e) => format!("failed to read body: {e}"),
other => format!("request body error: {other}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Router;
use axum::body::Body;
use axum::http::Request;
use axum::routing::post;
use http_body_util::BodyExt;
use serde::{Deserialize, Serialize};
use tower::ServiceExt;
#[derive(Debug, Deserialize, Serialize, PartialEq)]
struct Payload {
name: String,
age: u32,
}
async fn echo_handler(JsonBody(payload): JsonBody<Payload>) -> axum::Json<Payload> {
axum::Json(payload)
}
fn app() -> Router {
Router::new().route("/test", post(echo_handler))
}
async fn response_json(resp: Response) -> serde_json::Value {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn valid_json_body_deserializes() {
let body = r#"{"name":"Alice","age":30}"#;
let req = Request::builder()
.method("POST")
.uri("/test")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let resp = app().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = response_json(resp).await;
assert_eq!(json["name"], "Alice");
assert_eq!(json["age"], 30);
}
#[tokio::test]
async fn invalid_json_produces_422() {
let body = r#"{"name":"Alice","age":"not_a_number"}"#;
let req = Request::builder()
.method("POST")
.uri("/test")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let resp = app().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/json"
);
let json = response_json(resp).await;
assert_eq!(json["status"], 422);
assert!(json["error"].as_str().unwrap().contains("invalid JSON"));
}
#[tokio::test]
async fn malformed_json_produces_422() {
let body = r"{not valid json";
let req = Request::builder()
.method("POST")
.uri("/test")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let resp = app().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
let json = response_json(resp).await;
assert_eq!(json["status"], 422);
assert!(json["error"].as_str().unwrap().contains("malformed JSON"));
}
#[tokio::test]
async fn missing_content_type_produces_415() {
let body = r#"{"name":"Alice","age":30}"#;
let req = Request::builder()
.method("POST")
.uri("/test")
.body(Body::from(body))
.unwrap();
let resp = app().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/json"
);
let json = response_json(resp).await;
assert_eq!(json["status"], 415);
assert!(json["error"].as_str().unwrap().contains("application/json"));
}
#[tokio::test]
async fn wrong_content_type_produces_415() {
let body = r#"{"name":"Alice","age":30}"#;
let req = Request::builder()
.method("POST")
.uri("/test")
.header("content-type", "text/plain")
.body(Body::from(body))
.unwrap();
let resp = app().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
let json = response_json(resp).await;
assert_eq!(json["status"], 415);
}
#[test]
fn deref_to_inner_type() {
let body = JsonBody(Payload {
name: "Bob".into(),
age: 25,
});
assert_eq!(body.name, "Bob");
assert_eq!(body.age, 25);
}
#[tokio::test]
async fn content_type_with_charset_accepted() {
let body = r#"{"name":"Alice","age":30}"#;
let req = Request::builder()
.method("POST")
.uri("/test")
.header("content-type", "application/json; charset=utf-8")
.body(Body::from(body))
.unwrap();
let resp = app().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
}