1use std::ops::Deref;
20
21use axum::extract::FromRequest;
22use axum::extract::rejection::JsonRejection;
23use axum::http::{HeaderMap, StatusCode, header};
24use axum::response::{IntoResponse, Response};
25use serde::de::DeserializeOwned;
26
27use crate::fallback::json_error_response;
28
29pub struct JsonBody<T>(pub T);
34
35impl<T> Deref for JsonBody<T> {
36 type Target = T;
37
38 fn deref(&self) -> &T {
39 &self.0
40 }
41}
42
43impl<T> std::fmt::Debug for JsonBody<T>
44where
45 T: std::fmt::Debug,
46{
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 f.debug_tuple("JsonBody").field(&self.0).finish()
49 }
50}
51
52impl<T> Clone for JsonBody<T>
53where
54 T: Clone,
55{
56 fn clone(&self) -> Self {
57 Self(self.0.clone())
58 }
59}
60
61#[derive(Debug)]
63pub enum JsonBodyRejection {
64 UnsupportedMediaType,
66 InvalidJson(String),
68 PayloadTooLarge,
70}
71
72impl IntoResponse for JsonBodyRejection {
73 fn into_response(self) -> Response {
74 let (status, message) = match self {
75 Self::UnsupportedMediaType => (
76 StatusCode::UNSUPPORTED_MEDIA_TYPE,
77 "Content-Type must be application/json".to_string(),
78 ),
79 Self::InvalidJson(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg),
80 Self::PayloadTooLarge => (
81 StatusCode::PAYLOAD_TOO_LARGE,
82 "request body too large".to_string(),
83 ),
84 };
85 json_error_response(status, message)
86 }
87}
88
89fn has_json_content_type(headers: &HeaderMap) -> bool {
90 headers
91 .get(header::CONTENT_TYPE)
92 .and_then(|v| v.to_str().ok())
93 .is_some_and(|v| {
94 let mime = v.split(';').next().unwrap_or("").trim();
95 mime.eq_ignore_ascii_case("application/json")
96 })
97}
98
99impl<S, T> FromRequest<S> for JsonBody<T>
100where
101 S: Send + Sync,
102 T: DeserializeOwned,
103{
104 type Rejection = JsonBodyRejection;
105
106 async fn from_request(req: axum::extract::Request, state: &S) -> Result<Self, Self::Rejection> {
107 if !has_json_content_type(req.headers()) {
109 return Err(JsonBodyRejection::UnsupportedMediaType);
110 }
111
112 match axum::Json::<T>::from_request(req, state).await {
113 Ok(axum::Json(value)) => Ok(Self(value)),
114 Err(JsonRejection::BytesRejection(e)) => {
115 if e.status() == StatusCode::PAYLOAD_TOO_LARGE {
117 Err(JsonBodyRejection::PayloadTooLarge)
118 } else {
119 Err(JsonBodyRejection::InvalidJson(format!(
120 "failed to read body: {e}"
121 )))
122 }
123 }
124 Err(rejection) => Err(JsonBodyRejection::InvalidJson(rejection_message(rejection))),
125 }
126 }
127}
128
129fn rejection_message(rejection: JsonRejection) -> String {
130 match rejection {
131 JsonRejection::JsonDataError(e) => format!("invalid JSON: {e}"),
132 JsonRejection::JsonSyntaxError(e) => format!("malformed JSON: {e}"),
133 JsonRejection::MissingJsonContentType(_) => {
134 "Content-Type must be application/json".to_string()
135 }
136 JsonRejection::BytesRejection(e) => format!("failed to read body: {e}"),
137 other => format!("request body error: {other}"),
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use axum::Router;
145 use axum::body::Body;
146 use axum::http::Request;
147 use axum::routing::post;
148 use http_body_util::BodyExt;
149 use serde::{Deserialize, Serialize};
150 use tower::ServiceExt;
151
152 #[derive(Debug, Deserialize, Serialize, PartialEq)]
153 struct Payload {
154 name: String,
155 age: u32,
156 }
157
158 async fn echo_handler(JsonBody(payload): JsonBody<Payload>) -> axum::Json<Payload> {
159 axum::Json(payload)
160 }
161
162 fn app() -> Router {
163 Router::new().route("/test", post(echo_handler))
164 }
165
166 async fn response_json(resp: Response) -> serde_json::Value {
167 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
168 serde_json::from_slice(&bytes).unwrap()
169 }
170
171 #[tokio::test]
172 async fn valid_json_body_deserializes() {
173 let body = r#"{"name":"Alice","age":30}"#;
174 let req = Request::builder()
175 .method("POST")
176 .uri("/test")
177 .header("content-type", "application/json")
178 .body(Body::from(body))
179 .unwrap();
180
181 let resp = app().oneshot(req).await.unwrap();
182 assert_eq!(resp.status(), StatusCode::OK);
183 let json = response_json(resp).await;
184 assert_eq!(json["name"], "Alice");
185 assert_eq!(json["age"], 30);
186 }
187
188 #[tokio::test]
189 async fn invalid_json_produces_422() {
190 let body = r#"{"name":"Alice","age":"not_a_number"}"#;
191 let req = Request::builder()
192 .method("POST")
193 .uri("/test")
194 .header("content-type", "application/json")
195 .body(Body::from(body))
196 .unwrap();
197
198 let resp = app().oneshot(req).await.unwrap();
199 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
200 assert_eq!(
201 resp.headers().get("content-type").unwrap(),
202 "application/json"
203 );
204 let json = response_json(resp).await;
205 assert_eq!(json["status"], 422);
206 assert!(json["error"].as_str().unwrap().contains("invalid JSON"));
207 }
208
209 #[tokio::test]
210 async fn malformed_json_produces_422() {
211 let body = r"{not valid json";
212 let req = Request::builder()
213 .method("POST")
214 .uri("/test")
215 .header("content-type", "application/json")
216 .body(Body::from(body))
217 .unwrap();
218
219 let resp = app().oneshot(req).await.unwrap();
220 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
221 let json = response_json(resp).await;
222 assert_eq!(json["status"], 422);
223 assert!(json["error"].as_str().unwrap().contains("malformed JSON"));
224 }
225
226 #[tokio::test]
227 async fn missing_content_type_produces_415() {
228 let body = r#"{"name":"Alice","age":30}"#;
229 let req = Request::builder()
230 .method("POST")
231 .uri("/test")
232 .body(Body::from(body))
233 .unwrap();
234
235 let resp = app().oneshot(req).await.unwrap();
236 assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
237 assert_eq!(
238 resp.headers().get("content-type").unwrap(),
239 "application/json"
240 );
241 let json = response_json(resp).await;
242 assert_eq!(json["status"], 415);
243 assert!(json["error"].as_str().unwrap().contains("application/json"));
244 }
245
246 #[tokio::test]
247 async fn wrong_content_type_produces_415() {
248 let body = r#"{"name":"Alice","age":30}"#;
249 let req = Request::builder()
250 .method("POST")
251 .uri("/test")
252 .header("content-type", "text/plain")
253 .body(Body::from(body))
254 .unwrap();
255
256 let resp = app().oneshot(req).await.unwrap();
257 assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
258 let json = response_json(resp).await;
259 assert_eq!(json["status"], 415);
260 }
261
262 #[test]
263 fn deref_to_inner_type() {
264 let body = JsonBody(Payload {
265 name: "Bob".into(),
266 age: 25,
267 });
268 assert_eq!(body.name, "Bob");
270 assert_eq!(body.age, 25);
271 }
272
273 #[tokio::test]
274 async fn content_type_with_charset_accepted() {
275 let body = r#"{"name":"Alice","age":30}"#;
276 let req = Request::builder()
277 .method("POST")
278 .uri("/test")
279 .header("content-type", "application/json; charset=utf-8")
280 .body(Body::from(body))
281 .unwrap();
282
283 let resp = app().oneshot(req).await.unwrap();
284 assert_eq!(resp.status(), StatusCode::OK);
285 }
286}