Skip to main content

axum_api_kit/
problemjson.rs

1//! JSON body extractors that reject with RFC 9457 [`Problem`] responses.
2//!
3//! [`ProblemJson`] (features `problem` + `extract`) and [`ProblemValidatedJson`]
4//! (features `problem` + `validator`) are the problem-flavored siblings of
5//! [`ApiJson`](crate::ApiJson) and [`ValidatedJson`](crate::ValidatedJson):
6//! same deserialization (and validation) behavior, same HTTP status codes, but
7//! extraction failures short-circuit with an RFC 9457 problem details body
8//! instead of the flat [`ApiError`](crate::ApiError) shape.
9//!
10//! # Why sibling types instead of changing the existing extractors
11//!
12//! `ApiJson` and `ValidatedJson` rejection bodies (and their public
13//! `Rejection = (StatusCode, Json<ApiError>)` type) are frozen under the 1.x
14//! stability promise. Cargo unifies features across the dependency graph, so
15//! if enabling the `problem` feature changed those rejections, any transitive
16//! dependency turning the feature on would silently reformat error responses
17//! for unrelated users. The rejection format is therefore selected in code, by
18//! naming the extractor type in the handler signature, exactly like choosing
19//! `ApiJson` over `axum::Json`.
20//!
21//! # Failure mapping
22//!
23//! Failures reuse the exact `JsonRejection` -> [`ApiError`](crate::ApiError)
24//! mapping the siblings use, bridged through
25//! `From<(StatusCode, ApiError)> for Problem`, so the problem body carries the
26//! same information as the flat rejection:
27//!
28//! | flat rejection | `Problem` member |
29//! |---|---|
30//! | HTTP status | `status` (identical), plus `title` from the canonical reason |
31//! | `code` | `"code"` extension member (identical) |
32//! | `message` | `detail` (identical) |
33//! | `details` | `"details"` extension member (identical, when present) |
34//!
35//! # Content-Type negotiation
36//!
37//! Rejections negotiate their `Content-Type` from the request's `Accept`
38//! headers via [`ProblemFormat::negotiate`]: `application/problem+json` in
39//! every ambiguous case, plain `application/json` (byte-identical body) only
40//! when the client strictly prefers it. Negotiation is built in because the
41//! rejection fires before any handler code runs, so there is no later point
42//! where a caller could apply it.
43
44use axum::{
45    extract::{FromRequest, Request},
46    response::{IntoResponse, Response},
47    Json,
48};
49use serde::de::DeserializeOwned;
50#[cfg(feature = "extract")]
51use serde::Serialize;
52#[cfg(feature = "validator")]
53use validator::Validate;
54
55use crate::problem::{Problem, ProblemFormat};
56#[cfg(feature = "validator")]
57use crate::ApiError;
58
59/// The rejection produced by [`ProblemJson`] and [`ProblemValidatedJson`]: an
60/// RFC 9457 [`Problem`] plus the [`ProblemFormat`] negotiated from the
61/// request's `Accept` headers.
62///
63/// [`IntoResponse`] delegates to [`Problem::into_response_with`], so the
64/// response carries the negotiated `Content-Type` with the problem body. Both
65/// fields are public, making the rejection inspectable in tests and reusable
66/// as the rejection type of custom extractors.
67///
68/// Requires the `problem` feature plus at least one of `extract` or
69/// `validator`.
70///
71/// # Example
72///
73/// ```rust
74/// use axum::{http::{header, StatusCode}, response::IntoResponse};
75/// use axum_api_kit::{Problem, ProblemFormat, ProblemRejection};
76///
77/// let rejection = ProblemRejection {
78///     problem: Problem::new(StatusCode::UNPROCESSABLE_ENTITY, "Unprocessable Entity")
79///         .with_detail("validation failed"),
80///     format: ProblemFormat::ProblemJson,
81/// };
82/// let res = rejection.into_response();
83/// assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
84/// assert_eq!(
85///     res.headers().get(header::CONTENT_TYPE).unwrap(),
86///     "application/problem+json"
87/// );
88/// ```
89#[derive(Debug, Clone)]
90pub struct ProblemRejection {
91    /// The RFC 9457 problem details body (status, title, detail, and the
92    /// `code` / `details` extension members carried over from the flat
93    /// rejection mapping).
94    pub problem: Problem,
95    /// The response format negotiated from the request's `Accept` headers.
96    pub format: ProblemFormat,
97}
98
99impl IntoResponse for ProblemRejection {
100    fn into_response(self) -> Response {
101        self.problem.into_response_with(self.format)
102    }
103}
104
105/// Maps a `JsonRejection` onto a [`Problem`] by reusing the shared
106/// `JsonRejection` -> `ApiError` mapping and the lossless
107/// `From<(StatusCode, Json<ApiError>)>` bridge, keeping the status and the
108/// stable `code` identical to the sibling extractors.
109fn json_rejection_to_problem(rejection: axum::extract::rejection::JsonRejection) -> Problem {
110    Problem::from(crate::error::json_rejection_to_api_error(rejection))
111}
112
113/// The problem-flavored sibling of [`ApiJson`](crate::ApiJson): deserializes a
114/// JSON request body exactly like `axum::Json`, but extraction failures reject
115/// with an RFC 9457 [`Problem`] body via [`ProblemRejection`].
116///
117/// Status codes and the stable `code` extension member match
118/// [`ApiJson`](crate::ApiJson)'s rejections exactly:
119///
120/// - malformed JSON -> `400 Bad Request`, code `INVALID_JSON`
121/// - well-formed JSON of the wrong shape -> `422 Unprocessable Entity`, code
122///   `INVALID_BODY`
123/// - missing or incorrect `Content-Type` -> `415 Unsupported Media Type`, code
124///   `UNSUPPORTED_MEDIA_TYPE`
125///
126/// The problem body carries the same information the flat rejection would:
127/// `detail` is the same human-readable message, the stable `code` becomes the
128/// `"code"` extension member, and `title` comes from the status's canonical
129/// reason. The `Content-Type` is negotiated from the request's `Accept`
130/// headers via [`ProblemFormat::negotiate`]. It also implements
131/// [`IntoResponse`] (serializing as `200 OK` plain JSON), so it can be used
132/// as a handler return type just like `ApiJson`.
133///
134/// Requires the `problem` and `extract` features.
135///
136/// # Example
137///
138/// ```rust,no_run
139/// use axum_api_kit::ProblemJson;
140/// use serde::Deserialize;
141///
142/// #[derive(Deserialize)]
143/// struct CreateUser {
144///     name: String,
145/// }
146///
147/// // Bad input short-circuits with an RFC 9457 problem details body.
148/// async fn create_user(ProblemJson(user): ProblemJson<CreateUser>) {
149///     let _ = user.name;
150/// }
151/// ```
152#[cfg(feature = "extract")]
153#[derive(Debug, Clone)]
154pub struct ProblemJson<T>(pub T);
155
156#[cfg(feature = "extract")]
157impl<T, S> FromRequest<S> for ProblemJson<T>
158where
159    T: DeserializeOwned,
160    S: Send + Sync,
161{
162    type Rejection = ProblemRejection;
163
164    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
165        let format = ProblemFormat::negotiate(req.headers());
166        match Json::<T>::from_request(req, state).await {
167            Ok(Json(value)) => Ok(ProblemJson(value)),
168            Err(rejection) => Err(ProblemRejection {
169                problem: json_rejection_to_problem(rejection),
170                format,
171            }),
172        }
173    }
174}
175
176#[cfg(feature = "extract")]
177impl<T: Serialize> IntoResponse for ProblemJson<T> {
178    fn into_response(self) -> Response {
179        Json(self.0).into_response()
180    }
181}
182
183/// The problem-flavored sibling of [`ValidatedJson`](crate::ValidatedJson):
184/// deserializes a JSON request body, validates it with
185/// [`validator`](https://docs.rs/validator), and rejects with an RFC 9457
186/// [`Problem`] body via [`ProblemRejection`] on failure.
187///
188/// Status codes and the stable `code` extension member match
189/// [`ValidatedJson`](crate::ValidatedJson)'s rejections exactly:
190///
191/// - malformed JSON -> `400 Bad Request`, code `INVALID_JSON`
192/// - well-formed JSON of the wrong shape -> `422 Unprocessable Entity`, code
193///   `INVALID_BODY`
194/// - missing or incorrect `Content-Type` -> `415 Unsupported Media Type`, code
195///   `UNSUPPORTED_MEDIA_TYPE`
196/// - validation failure -> `422 Unprocessable Entity`, code `VALIDATION_ERROR`
197///
198/// A validation failure carries the same field-level map the flat rejection
199/// exposes, under the `"details"` extension member:
200///
201/// ```json
202/// {
203///   "title": "Unprocessable Entity",
204///   "status": 422,
205///   "detail": "validation failed",
206///   "code": "VALIDATION_ERROR",
207///   "details": { "fields": { "name": [{ "code": "length", "params": { "min": 2 } }] } }
208/// }
209/// ```
210///
211/// As with [`ProblemJson`](crate::ProblemJson), `detail` carries the flat
212/// rejection's message, `code` becomes the `"code"` extension member, and the
213/// `Content-Type` is negotiated from the request's `Accept` headers via
214/// [`ProblemFormat::negotiate`].
215///
216/// Requires the `problem` and `validator` features.
217///
218/// # Example
219///
220/// ```rust,no_run
221/// use axum_api_kit::ProblemValidatedJson;
222/// use serde::Deserialize;
223/// use validator::Validate;
224///
225/// #[derive(Deserialize, Validate)]
226/// struct CreateUser {
227///     #[validate(length(min = 1, max = 100))]
228///     name: String,
229///     #[validate(email)]
230///     email: String,
231/// }
232///
233/// // The body is deserialized and validated before the handler body runs;
234/// // failures become RFC 9457 problem details responses.
235/// async fn create_user(ProblemValidatedJson(user): ProblemValidatedJson<CreateUser>) {
236///     let _ = (user.name, user.email);
237/// }
238/// ```
239#[cfg(feature = "validator")]
240#[derive(Debug, Clone)]
241pub struct ProblemValidatedJson<T>(pub T);
242
243#[cfg(feature = "validator")]
244impl<T, S> FromRequest<S> for ProblemValidatedJson<T>
245where
246    T: DeserializeOwned + Validate,
247    S: Send + Sync,
248{
249    type Rejection = ProblemRejection;
250
251    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
252        let format = ProblemFormat::negotiate(req.headers());
253        let value = match Json::<T>::from_request(req, state).await {
254            Ok(Json(value)) => value,
255            Err(rejection) => {
256                return Err(ProblemRejection {
257                    problem: json_rejection_to_problem(rejection),
258                    format,
259                })
260            }
261        };
262        if let Err(errors) = value.validate() {
263            return Err(ProblemRejection {
264                problem: Problem::from((
265                    axum::http::StatusCode::UNPROCESSABLE_ENTITY,
266                    ApiError::from(errors),
267                )),
268                format,
269            });
270        }
271        Ok(ProblemValidatedJson(value))
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use axum::http::{header, StatusCode};
279
280    #[tokio::test]
281    async fn problem_rejection_into_response_respects_format_with_identical_bodies() {
282        let rejection = |format| ProblemRejection {
283            problem: Problem::new(StatusCode::BAD_REQUEST, "Bad Request").with_detail("nope"),
284            format,
285        };
286
287        let default = rejection(ProblemFormat::ProblemJson).into_response();
288        assert_eq!(default.status(), StatusCode::BAD_REQUEST);
289        assert_eq!(
290            default.headers().get(header::CONTENT_TYPE).unwrap(),
291            "application/problem+json"
292        );
293
294        let negotiated = rejection(ProblemFormat::Json).into_response();
295        assert_eq!(negotiated.status(), StatusCode::BAD_REQUEST);
296        assert_eq!(
297            negotiated.headers().get(header::CONTENT_TYPE).unwrap(),
298            "application/json"
299        );
300
301        let default_body = axum::body::to_bytes(default.into_body(), usize::MAX)
302            .await
303            .unwrap();
304        let negotiated_body = axum::body::to_bytes(negotiated.into_body(), usize::MAX)
305            .await
306            .unwrap();
307        assert_eq!(
308            default_body, negotiated_body,
309            "bodies must be byte-identical across formats"
310        );
311    }
312
313    #[cfg(feature = "extract")]
314    mod problem_json {
315        use super::*;
316        use axum::body::Body;
317        use axum::http::{header::ACCEPT, header::CONTENT_TYPE, Request};
318        use serde::Deserialize;
319
320        #[derive(Debug, Deserialize)]
321        struct Input {
322            name: String,
323        }
324
325        fn request(body: &str, content_type: Option<&str>, accept: Option<&str>) -> Request<Body> {
326            let mut builder = Request::builder().method("POST").uri("/");
327            if let Some(value) = content_type {
328                builder = builder.header(CONTENT_TYPE, value);
329            }
330            if let Some(value) = accept {
331                builder = builder.header(ACCEPT, value);
332            }
333            builder.body(Body::from(body.to_owned())).unwrap()
334        }
335
336        async fn extract(
337            body: &str,
338            content_type: Option<&str>,
339        ) -> Result<Input, ProblemRejection> {
340            ProblemJson::<Input>::from_request(request(body, content_type, None), &())
341                .await
342                .map(|ProblemJson(v)| v)
343        }
344
345        #[tokio::test]
346        async fn valid_body_extracts() {
347            let input = extract(r#"{"name":"abc"}"#, Some("application/json"))
348                .await
349                .unwrap();
350            assert_eq!(input.name, "abc");
351        }
352
353        #[tokio::test]
354        async fn malformed_json_is_invalid_json_problem() {
355            let rejection = extract("{not json", Some("application/json"))
356                .await
357                .unwrap_err();
358            assert_eq!(rejection.problem.status, 400);
359            assert_eq!(rejection.problem.title, "Bad Request");
360            assert_eq!(rejection.problem.extensions["code"], "INVALID_JSON");
361            assert!(rejection.problem.detail.is_some());
362            assert_eq!(rejection.format, ProblemFormat::ProblemJson);
363        }
364
365        #[tokio::test]
366        async fn wrong_shape_is_invalid_body_problem() {
367            let rejection = extract(r#"{"name":123}"#, Some("application/json"))
368                .await
369                .unwrap_err();
370            assert_eq!(rejection.problem.status, 422);
371            assert_eq!(rejection.problem.title, "Unprocessable Entity");
372            assert_eq!(rejection.problem.extensions["code"], "INVALID_BODY");
373        }
374
375        #[tokio::test]
376        async fn missing_content_type_is_unsupported_media_type_problem() {
377            let rejection = extract(r#"{"name":"abc"}"#, None).await.unwrap_err();
378            assert_eq!(rejection.problem.status, 415);
379            assert_eq!(rejection.problem.title, "Unsupported Media Type");
380            assert_eq!(
381                rejection.problem.extensions["code"],
382                "UNSUPPORTED_MEDIA_TYPE"
383            );
384        }
385
386        #[tokio::test]
387        async fn wrong_content_type_is_unsupported_media_type_problem() {
388            let rejection = extract(r#"{"name":"abc"}"#, Some("text/plain"))
389                .await
390                .unwrap_err();
391            assert_eq!(rejection.problem.status, 415);
392            assert_eq!(
393                rejection.problem.extensions["code"],
394                "UNSUPPORTED_MEDIA_TYPE"
395            );
396        }
397
398        #[tokio::test]
399        async fn json_with_charset_is_accepted() {
400            let input = extract(r#"{"name":"abc"}"#, Some("application/json; charset=utf-8"))
401                .await
402                .unwrap();
403            assert_eq!(input.name, "abc");
404        }
405
406        #[tokio::test]
407        async fn vendor_plus_json_is_accepted() {
408            let input = extract(r#"{"name":"abc"}"#, Some("application/vnd.api+json"))
409                .await
410                .unwrap();
411            assert_eq!(input.name, "abc");
412        }
413
414        #[tokio::test]
415        async fn rejection_carries_same_information_as_apijson() {
416            use crate::ApiJson;
417
418            for (body, content_type) in [
419                ("{not json", Some("application/json")),
420                (r#"{"name":123}"#, Some("application/json")),
421                (r#"{"name":"abc"}"#, None),
422            ] {
423                let problem_rejection =
424                    ProblemJson::<Input>::from_request(request(body, content_type, None), &())
425                        .await
426                        .unwrap_err();
427                let (status, Json(api_error)) =
428                    ApiJson::<Input>::from_request(request(body, content_type, None), &())
429                        .await
430                        .unwrap_err();
431
432                let problem = &problem_rejection.problem;
433                assert_eq!(problem.status, status.as_u16(), "body {body:?}");
434                assert_eq!(problem.extensions["code"], api_error.code, "body {body:?}");
435                assert_eq!(
436                    problem.detail.as_deref(),
437                    Some(api_error.message.as_str()),
438                    "body {body:?}"
439                );
440                assert_eq!(
441                    problem.extensions.get("details"),
442                    api_error.details.as_ref(),
443                    "body {body:?}"
444                );
445            }
446        }
447
448        #[tokio::test]
449        async fn rejection_negotiates_format_from_accept_header() {
450            let rejection = ProblemJson::<Input>::from_request(
451                request(
452                    "{not json",
453                    Some("application/json"),
454                    Some("application/json"),
455                ),
456                &(),
457            )
458            .await
459            .unwrap_err();
460            assert_eq!(rejection.format, ProblemFormat::Json);
461            let res = rejection.into_response();
462            assert_eq!(res.status(), StatusCode::BAD_REQUEST);
463            assert_eq!(
464                res.headers().get(header::CONTENT_TYPE).unwrap(),
465                "application/json"
466            );
467
468            for accept in [None, Some("*/*"), Some("text/html")] {
469                let rejection = ProblemJson::<Input>::from_request(
470                    request("{not json", Some("application/json"), accept),
471                    &(),
472                )
473                .await
474                .unwrap_err();
475                assert_eq!(
476                    rejection.format,
477                    ProblemFormat::ProblemJson,
478                    "Accept: {accept:?}"
479                );
480            }
481        }
482
483        #[tokio::test]
484        async fn rejection_response_serves_problem_wire_shape() {
485            let res = extract("{not json", Some("application/json"))
486                .await
487                .unwrap_err()
488                .into_response();
489            assert_eq!(res.status(), StatusCode::BAD_REQUEST);
490            assert_eq!(
491                res.headers().get(header::CONTENT_TYPE).unwrap(),
492                "application/problem+json"
493            );
494            let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
495                .await
496                .unwrap();
497            let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
498            assert_eq!(body["title"], "Bad Request");
499            assert_eq!(body["status"], 400);
500            assert_eq!(body["code"], "INVALID_JSON");
501            assert!(body["detail"].is_string());
502        }
503
504        #[tokio::test]
505        async fn serializes_as_response() {
506            use serde::Serialize;
507
508            #[derive(Serialize)]
509            struct Out {
510                id: u32,
511            }
512            let res = ProblemJson(Out { id: 7 }).into_response();
513            assert_eq!(res.status(), StatusCode::OK);
514            let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
515                .await
516                .unwrap();
517            let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
518            assert_eq!(v["id"], 7);
519        }
520    }
521
522    #[cfg(feature = "validator")]
523    mod problem_validated_json {
524        use super::*;
525        use axum::body::Body;
526        use axum::http::{header::ACCEPT, header::CONTENT_TYPE, Request};
527        use serde::Deserialize;
528
529        #[derive(Debug, Deserialize, Validate)]
530        struct Input {
531            #[validate(length(min = 2))]
532            name: String,
533        }
534
535        fn request(body: &str, content_type: Option<&str>, accept: Option<&str>) -> Request<Body> {
536            let mut builder = Request::builder().method("POST").uri("/");
537            if let Some(value) = content_type {
538                builder = builder.header(CONTENT_TYPE, value);
539            }
540            if let Some(value) = accept {
541                builder = builder.header(ACCEPT, value);
542            }
543            builder.body(Body::from(body.to_owned())).unwrap()
544        }
545
546        async fn extract(
547            body: &str,
548            content_type: Option<&str>,
549        ) -> Result<Input, ProblemRejection> {
550            ProblemValidatedJson::<Input>::from_request(request(body, content_type, None), &())
551                .await
552                .map(|ProblemValidatedJson(v)| v)
553        }
554
555        #[tokio::test]
556        async fn valid_body_extracts() {
557            let input = extract(r#"{"name":"abcd"}"#, Some("application/json"))
558                .await
559                .unwrap();
560            assert_eq!(input.name, "abcd");
561        }
562
563        #[tokio::test]
564        async fn malformed_json_is_invalid_json_problem() {
565            let rejection = extract("{not json", Some("application/json"))
566                .await
567                .unwrap_err();
568            assert_eq!(rejection.problem.status, 400);
569            assert_eq!(rejection.problem.extensions["code"], "INVALID_JSON");
570        }
571
572        #[tokio::test]
573        async fn wrong_shape_is_invalid_body_problem() {
574            let rejection = extract(r#"{"name":123}"#, Some("application/json"))
575                .await
576                .unwrap_err();
577            assert_eq!(rejection.problem.status, 422);
578            assert_eq!(rejection.problem.extensions["code"], "INVALID_BODY");
579        }
580
581        #[tokio::test]
582        async fn missing_content_type_is_unsupported_media_type_problem() {
583            let rejection = extract(r#"{"name":"abcd"}"#, None).await.unwrap_err();
584            assert_eq!(rejection.problem.status, 415);
585            assert_eq!(
586                rejection.problem.extensions["code"],
587                "UNSUPPORTED_MEDIA_TYPE"
588            );
589        }
590
591        #[tokio::test]
592        async fn validation_failure_is_validation_error_problem_with_field_details() {
593            let rejection = extract(r#"{"name":"a"}"#, Some("application/json"))
594                .await
595                .unwrap_err();
596            assert_eq!(rejection.problem.status, 422);
597            assert_eq!(rejection.problem.title, "Unprocessable Entity");
598            assert_eq!(rejection.problem.extensions["code"], "VALIDATION_ERROR");
599            assert_eq!(
600                rejection.problem.detail.as_deref(),
601                Some("validation failed")
602            );
603            assert!(rejection.problem.extensions["details"]["fields"]["name"].is_array());
604        }
605
606        #[tokio::test]
607        async fn validation_failure_carries_same_information_as_validated_json() {
608            use crate::ValidatedJson;
609
610            let body = r#"{"name":"a"}"#;
611            let problem_rejection = ProblemValidatedJson::<Input>::from_request(
612                request(body, Some("application/json"), None),
613                &(),
614            )
615            .await
616            .unwrap_err();
617            let (status, Json(api_error)) = ValidatedJson::<Input>::from_request(
618                request(body, Some("application/json"), None),
619                &(),
620            )
621            .await
622            .unwrap_err();
623
624            let problem = &problem_rejection.problem;
625            assert_eq!(problem.status, status.as_u16());
626            assert_eq!(problem.extensions["code"], api_error.code);
627            assert_eq!(problem.detail.as_deref(), Some(api_error.message.as_str()));
628            assert_eq!(
629                problem.extensions.get("details"),
630                api_error.details.as_ref()
631            );
632        }
633
634        #[tokio::test]
635        async fn validation_rejection_negotiates_format_from_accept_header() {
636            let rejection = ProblemValidatedJson::<Input>::from_request(
637                request(
638                    r#"{"name":"a"}"#,
639                    Some("application/json"),
640                    Some("application/json"),
641                ),
642                &(),
643            )
644            .await
645            .unwrap_err();
646            assert_eq!(rejection.format, ProblemFormat::Json);
647            let res = rejection.into_response();
648            assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
649            assert_eq!(
650                res.headers().get(header::CONTENT_TYPE).unwrap(),
651                "application/json"
652            );
653        }
654    }
655}