Skip to main content

axum_api_kit/
apijson.rs

1use axum::{
2    extract::{FromRequest, Request},
3    http::StatusCode,
4    response::{IntoResponse, Response},
5    Json,
6};
7use serde::{de::DeserializeOwned, Serialize};
8
9use crate::error::json_rejection_to_api_error;
10use crate::ApiError;
11
12/// A drop-in replacement for [`axum::Json`] whose extraction failures reject with an
13/// [`ApiError`] JSON body instead of Axum's default plain-text response.
14///
15/// As an extractor it deserializes the request body exactly like `axum::Json`, but on
16/// failure it short-circuits the handler with `(StatusCode, Json<ApiError>)`:
17///
18/// - malformed JSON -> `400 Bad Request`, code `INVALID_JSON`
19/// - well-formed JSON of the wrong shape -> `422 Unprocessable Entity`, code `INVALID_BODY`
20/// - missing or incorrect `Content-Type` -> `415 Unsupported Media Type`, code
21///   `UNSUPPORTED_MEDIA_TYPE`
22///
23/// The HTTP status is taken from Axum's own rejection, so it stays correct as Axum evolves.
24/// It also implements [`IntoResponse`] (serializing as `200 OK` JSON), so it can be used as a
25/// handler return type just like `axum::Json`.
26///
27/// Unlike [`ValidatedJson`](crate::ValidatedJson), `ApiJson` performs no validation and so
28/// needs only the `extract` feature, not `validator`. Reach for `ValidatedJson` when you want
29/// `validator`-based field validation as well.
30///
31/// Requires the `extract` feature.
32///
33/// # Example
34///
35/// ```rust,no_run
36/// use axum_api_kit::ApiJson;
37/// use serde::Deserialize;
38///
39/// #[derive(Deserialize)]
40/// struct CreateUser {
41///     name: String,
42/// }
43///
44/// // The body is deserialized before the handler runs; bad input becomes an ApiError body.
45/// async fn create_user(ApiJson(user): ApiJson<CreateUser>) {
46///     let _ = user.name;
47/// }
48/// ```
49#[derive(Debug, Clone)]
50pub struct ApiJson<T>(pub T);
51
52impl<T, S> FromRequest<S> for ApiJson<T>
53where
54    T: DeserializeOwned,
55    S: Send + Sync,
56{
57    type Rejection = (StatusCode, Json<ApiError>);
58
59    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
60        let Json(value) = Json::<T>::from_request(req, state)
61            .await
62            .map_err(json_rejection_to_api_error)?;
63        Ok(ApiJson(value))
64    }
65}
66
67impl<T: Serialize> IntoResponse for ApiJson<T> {
68    fn into_response(self) -> Response {
69        Json(self.0).into_response()
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use axum::{body::Body, http::header::CONTENT_TYPE, http::Request};
77    use serde::Deserialize;
78
79    #[derive(Debug, Deserialize)]
80    struct Input {
81        name: String,
82    }
83
84    async fn extract(
85        body: &str,
86        content_type: Option<&str>,
87    ) -> Result<Input, (StatusCode, ApiError)> {
88        let mut builder = Request::builder().method("POST").uri("/");
89        if let Some(value) = content_type {
90            builder = builder.header(CONTENT_TYPE, value);
91        }
92        let req = builder.body(Body::from(body.to_owned())).unwrap();
93        ApiJson::<Input>::from_request(req, &())
94            .await
95            .map(|ApiJson(v)| v)
96            .map_err(|(status, Json(err))| (status, err))
97    }
98
99    #[tokio::test]
100    async fn valid_body_extracts() {
101        let input = extract(r#"{"name":"abc"}"#, Some("application/json"))
102            .await
103            .unwrap();
104        assert_eq!(input.name, "abc");
105    }
106
107    #[tokio::test]
108    async fn malformed_json_is_invalid_json() {
109        let (status, err) = extract("{not json", Some("application/json"))
110            .await
111            .unwrap_err();
112        assert_eq!(status, StatusCode::BAD_REQUEST);
113        assert_eq!(err.code, "INVALID_JSON");
114    }
115
116    #[tokio::test]
117    async fn wrong_shape_is_invalid_body() {
118        let (status, err) = extract(r#"{"name":123}"#, Some("application/json"))
119            .await
120            .unwrap_err();
121        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
122        assert_eq!(err.code, "INVALID_BODY");
123    }
124
125    #[tokio::test]
126    async fn missing_content_type_is_unsupported_media_type() {
127        let (status, err) = extract(r#"{"name":"abc"}"#, None).await.unwrap_err();
128        assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
129        assert_eq!(err.code, "UNSUPPORTED_MEDIA_TYPE");
130    }
131
132    #[tokio::test]
133    async fn json_with_charset_is_accepted() {
134        let input = extract(r#"{"name":"abc"}"#, Some("application/json; charset=utf-8"))
135            .await
136            .unwrap();
137        assert_eq!(input.name, "abc");
138    }
139
140    #[tokio::test]
141    async fn vendor_plus_json_is_accepted() {
142        let input = extract(r#"{"name":"abc"}"#, Some("application/vnd.api+json"))
143            .await
144            .unwrap();
145        assert_eq!(input.name, "abc");
146    }
147
148    #[tokio::test]
149    async fn serializes_as_response() {
150        #[derive(Serialize)]
151        struct Out {
152            id: u32,
153        }
154        let res = ApiJson(Out { id: 7 }).into_response();
155        assert_eq!(res.status(), StatusCode::OK);
156        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
157            .await
158            .unwrap();
159        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
160        assert_eq!(v["id"], 7);
161    }
162}