Skip to main content

aro_web/
json_body.rs

1//! JSON body extractor with consistent error responses.
2//!
3//! [`JsonBody<T>`] wraps Axum's `Json<T>` extractor and converts
4//! parse failures into the standard Aro JSON error envelope format,
5//! rather than Axum's default plain-text error responses.
6//!
7//! # Example
8//!
9//! ```ignore
10//! use aro_web::json_body::JsonBody;
11//!
12//! #[aro::post("/users")]
13//! async fn create_user(JsonBody(payload): JsonBody<CreateUser>) -> Json<User> {
14//!     // payload is already deserialized; parse errors returned as JSON
15//!     Json(User { name: payload.name })
16//! }
17//! ```
18
19use 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
29/// A JSON body extractor that produces Aro-consistent error responses.
30///
31/// On success, wraps the deserialized value of type `T`.
32/// On failure, returns a JSON error response with the appropriate status code.
33pub 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/// Rejection type for [`JsonBody`] extraction failures.
62#[derive(Debug)]
63pub enum JsonBodyRejection {
64    /// The Content-Type header is missing or not `application/json`.
65    UnsupportedMediaType,
66    /// The request body could not be parsed as valid JSON.
67    InvalidJson(String),
68    /// The request body exceeded the configured size limit.
69    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        // Check Content-Type before attempting parse
108        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                // Preserve 413 for body limit errors
116                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        // Deref allows direct field access
269        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}