Skip to main content

toolkit/api/rest/extract/
json.rs

1//! `Json<T>` - a drop-in `axum::Json<T>` replacement, usable as both a
2//! request extractor and a response body, whose extraction failures render
3//! as `application/problem+json` (RFC 9457 `Problem`) instead of axum's
4//! default plain-text rejection body.
5//!
6//! `axum::Json<T>`'s `FromRequest` impl resolves and, on failure, calls
7//! `.into_response()` on its own `JsonRejection` directly - inside axum's
8//! generated `Handler::call`, before the handler body ever runs. No
9//! handler-level `CanonicalError` conversion is ever in the loop for a
10//! malformed body. `Json<T>` closes that gap by making the extractor's own
11//! `Rejection` a `CanonicalError`.
12//!
13//! `IntoResponse` and `From<T>` delegate to `axum::Json`'s own impls, so the
14//! common `Json(body): Json<Req> -> ... -> Json(resp)` handler shape
15//! compiles unchanged when swapping the import from `axum::Json` to this
16//! type.
17
18use axum::Json as AxumJson;
19use axum::extract::{FromRequest, Request};
20use axum::response::{IntoResponse, Response};
21use serde::Serialize;
22use toolkit_canonical_errors::CanonicalError;
23
24use super::error::json_rejection_to_canonical;
25
26/// Drop-in replacement for `axum::Json<T>` as a handler parameter or
27/// response body. Extraction success and response serialization are
28/// identical to `axum::Json<T>`; extraction failure produces a
29/// `CanonicalError` (rendered as `Problem` by its existing `IntoResponse`
30/// impl) instead of `JsonRejection`'s plain-text body.
31#[derive(Debug, Clone, Copy, Default)]
32pub struct Json<T>(pub T);
33
34impl<T, S> FromRequest<S> for Json<T>
35where
36    T: serde::de::DeserializeOwned,
37    S: Send + Sync,
38{
39    type Rejection = CanonicalError;
40
41    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
42        match AxumJson::<T>::from_request(req, state).await {
43            Ok(AxumJson(value)) => Ok(Self(value)),
44            Err(rejection) => Err(json_rejection_to_canonical(&rejection)),
45        }
46    }
47}
48
49impl<T> From<T> for Json<T> {
50    fn from(value: T) -> Self {
51        Self(value)
52    }
53}
54
55impl<T: Serialize> IntoResponse for Json<T> {
56    fn into_response(self) -> Response {
57        AxumJson(self.0).into_response()
58    }
59}
60
61#[cfg(test)]
62#[cfg_attr(coverage_nightly, coverage(off))]
63mod tests {
64    use axum::Router;
65    use axum::body::Body;
66    use axum::http::{Request as HttpRequest, StatusCode, header};
67    use axum::routing::post;
68    use serde::{Deserialize, Serialize};
69    use serde_json::{Value, json};
70    use tower::ServiceExt;
71
72    use super::Json;
73
74    const RESOURCE_TYPE: &str = "gts.cf.core.http.request.v1~";
75    const INVALID_ARGUMENT_TYPE: &str =
76        "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~";
77
78    #[derive(Debug, Deserialize, Serialize)]
79    #[serde(deny_unknown_fields)]
80    struct Widget {
81        name: String,
82    }
83
84    fn app() -> Router {
85        Router::new().route(
86            "/widgets",
87            post(|Json(w): Json<Widget>| async move { (StatusCode::CREATED, Json(w)) }),
88        )
89    }
90
91    async fn post_body(body: &'static str, content_type: &str) -> axum::response::Response {
92        let req = HttpRequest::builder()
93            .method("POST")
94            .uri("/widgets")
95            .header(header::CONTENT_TYPE, content_type)
96            .body(Body::from(body))
97            .unwrap();
98        app().oneshot(req).await.unwrap()
99    }
100
101    async fn body_json(response: axum::response::Response) -> Value {
102        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
103            .await
104            .unwrap();
105        serde_json::from_slice(&bytes).expect("response body is valid JSON")
106    }
107
108    #[tokio::test]
109    async fn valid_body_extracts_normally() {
110        // Echoes the extracted value back via the `IntoResponse` impl and
111        // asserts it, not just the status - a `Json<T>` that silently
112        // yielded a wrong, empty, or defaulted payload would still return
113        // 201 and pass a status-only assertion.
114        let res = post_body(r#"{"name":"a"}"#, "application/json").await;
115        assert_eq!(res.status(), StatusCode::CREATED);
116        let json = body_json(res).await;
117        assert_eq!(json, json!({"name": "a"}));
118    }
119
120    #[tokio::test]
121    async fn usable_as_a_response_body() {
122        // The common `Json(body): Json<Req> -> ... -> Json(resp)` handler
123        // shape must compile and round-trip unchanged from `axum::Json`.
124        let app = Router::new().route(
125            "/widgets",
126            post(|Json(w): Json<Widget>| async move { Json(w) }),
127        );
128        let req = HttpRequest::builder()
129            .method("POST")
130            .uri("/widgets")
131            .header(header::CONTENT_TYPE, "application/json")
132            .body(Body::from(r#"{"name":"a"}"#))
133            .unwrap();
134        let res = app.oneshot(req).await.unwrap();
135
136        assert_eq!(res.status(), StatusCode::OK);
137        assert_eq!(
138            res.headers().get(header::CONTENT_TYPE).unwrap(),
139            "application/json"
140        );
141        let json = body_json(res).await;
142        assert_eq!(json, json!({"name": "a"}));
143    }
144
145    #[tokio::test]
146    async fn malformed_json_returns_400_problem() {
147        let res = post_body("{not-json}", "application/json").await;
148        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
149        assert_eq!(
150            res.headers().get(header::CONTENT_TYPE).unwrap(),
151            "application/problem+json"
152        );
153        let json = body_json(res).await;
154        assert_eq!(
155            json,
156            json!({
157                "type": INVALID_ARGUMENT_TYPE,
158                "title": "Invalid Argument",
159                "status": 400,
160                "detail": "Request validation failed",
161                "context": {
162                    "resource_type": RESOURCE_TYPE,
163                    "field_violations": [{
164                        "field": "body",
165                        "description": "Failed to parse the request body as JSON: key must be a string at line 1 column 2",
166                        "reason": "json_syntax_error",
167                    }],
168                },
169            })
170        );
171    }
172
173    #[tokio::test]
174    async fn unknown_field_returns_422_problem_with_code() {
175        let res = post_body(r#"{"name":"a","extra":1}"#, "application/json").await;
176        assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
177        assert_eq!(
178            res.headers().get(header::CONTENT_TYPE).unwrap(),
179            "application/problem+json"
180        );
181        let json = body_json(res).await;
182        assert_eq!(
183            json,
184            json!({
185                "type": INVALID_ARGUMENT_TYPE,
186                "title": "Invalid Argument",
187                "status": 422,
188                "detail": "Request validation failed",
189                "context": {
190                    "resource_type": RESOURCE_TYPE,
191                    "field_violations": [{
192                        "field": "body",
193                        "description": "Failed to deserialize the JSON body into the target type: extra: unknown field `extra`, expected `name` at line 1 column 19",
194                        "reason": "invalid_json_body",
195                    }],
196                },
197            })
198        );
199    }
200
201    #[tokio::test]
202    async fn invalid_enum_variant_returns_422_problem() {
203        #[derive(Debug, Deserialize)]
204        #[serde(rename_all = "snake_case")]
205        #[allow(dead_code)]
206        enum Mode {
207            Monotonic,
208            Stateless,
209        }
210
211        #[derive(Debug, Deserialize)]
212        struct WithMode {
213            #[allow(dead_code)]
214            mode: Mode,
215        }
216
217        let app = Router::new().route(
218            "/producers",
219            post(|Json(_w): Json<WithMode>| async { StatusCode::CREATED }),
220        );
221        let req = HttpRequest::builder()
222            .method("POST")
223            .uri("/producers")
224            .header(header::CONTENT_TYPE, "application/json")
225            .body(Body::from(r#"{"mode":"not_a_real_mode"}"#))
226            .unwrap();
227        let res = app.oneshot(req).await.unwrap();
228
229        assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
230        assert_eq!(
231            res.headers().get(header::CONTENT_TYPE).unwrap(),
232            "application/problem+json"
233        );
234        let json = body_json(res).await;
235        assert_eq!(
236            json,
237            json!({
238                "type": INVALID_ARGUMENT_TYPE,
239                "title": "Invalid Argument",
240                "status": 422,
241                "detail": "Request validation failed",
242                "context": {
243                    "resource_type": RESOURCE_TYPE,
244                    "field_violations": [{
245                        "field": "body",
246                        "description": "Failed to deserialize the JSON body into the target type: mode: unknown variant `not_a_real_mode`, expected `monotonic` or `stateless` at line 1 column 25",
247                        "reason": "invalid_json_body",
248                    }],
249                },
250            })
251        );
252    }
253
254    #[tokio::test]
255    async fn missing_content_type_returns_415_problem() {
256        let res = post_body(r#"{"name":"a"}"#, "text/plain").await;
257        assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
258        assert_eq!(
259            res.headers().get(header::CONTENT_TYPE).unwrap(),
260            "application/problem+json"
261        );
262        let json = body_json(res).await;
263        assert_eq!(
264            json,
265            json!({
266                "type": INVALID_ARGUMENT_TYPE,
267                "title": "Invalid Argument",
268                "status": 415,
269                "detail": "Request validation failed",
270                "context": {
271                    "resource_type": RESOURCE_TYPE,
272                    "field_violations": [{
273                        "field": "body",
274                        "description": "Expected request with `Content-Type: application/json`",
275                        "reason": "missing_json_content_type",
276                    }],
277                },
278            })
279        );
280    }
281
282    #[tokio::test]
283    async fn oversized_body_returns_problem_with_axum_status() {
284        use axum::extract::DefaultBodyLimit;
285
286        let app = Router::new()
287            .route(
288                "/widgets",
289                post(|Json(_w): Json<Widget>| async { StatusCode::CREATED }),
290            )
291            .layer(DefaultBodyLimit::max(4));
292
293        let req = HttpRequest::builder()
294            .method("POST")
295            .uri("/widgets")
296            .header(header::CONTENT_TYPE, "application/json")
297            .body(Body::from(r#"{"name":"a"}"#))
298            .unwrap();
299        let res = app.oneshot(req).await.unwrap();
300
301        // `DefaultBodyLimit::max` reports an over-limit body as a
302        // `BytesRejection(LengthLimitError)`, which axum resolves to exactly
303        // 413 - assert that precisely rather than any 4xx.
304        assert_eq!(res.status(), StatusCode::PAYLOAD_TOO_LARGE);
305        assert_eq!(
306            res.headers().get(header::CONTENT_TYPE).unwrap(),
307            "application/problem+json"
308        );
309        let json = body_json(res).await;
310        assert_eq!(
311            json,
312            json!({
313                "type": INVALID_ARGUMENT_TYPE,
314                "title": "Invalid Argument",
315                "status": 413,
316                "detail": "Request validation failed",
317                "context": {
318                    "resource_type": RESOURCE_TYPE,
319                    "field_violations": [{
320                        "field": "body",
321                        "description": "Failed to buffer the request body: length limit exceeded",
322                        "reason": "json_body_read_error",
323                    }],
324                },
325            })
326        );
327    }
328
329    #[tokio::test]
330    async fn usable_via_canonical_prelude_glob_import() {
331        // No explicit `use super::Json` here - only the prelude's glob
332        // import, mirroring how a handler module actually adopts it. The
333        // prelude re-exports the `extract` module (not a flattened
334        // name), so the type is reached as `extract::Json`.
335        use crate::api::canonical_prelude::*;
336
337        #[derive(Debug, Deserialize)]
338        struct FromPrelude {
339            #[allow(dead_code)]
340            name: String,
341        }
342
343        let app = Router::new().route(
344            "/via-prelude",
345            post(|extract::Json(_w): extract::Json<FromPrelude>| async { StatusCode::CREATED }),
346        );
347        let req = HttpRequest::builder()
348            .method("POST")
349            .uri("/via-prelude")
350            .header(header::CONTENT_TYPE, "application/json")
351            .body(Body::from(r#"{"name":"a"}"#))
352            .unwrap();
353        let res = app.oneshot(req).await.unwrap();
354        assert_eq!(res.status(), StatusCode::CREATED);
355    }
356}