toolkit/api/rest/extract/
json.rs1use 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#[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 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 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 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 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}