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(body: &str, with_content_type: bool) -> Result<Input, (StatusCode, ApiError)> {
85        let mut builder = Request::builder().method("POST").uri("/");
86        if with_content_type {
87            builder = builder.header(CONTENT_TYPE, "application/json");
88        }
89        let req = builder.body(Body::from(body.to_owned())).unwrap();
90        ApiJson::<Input>::from_request(req, &())
91            .await
92            .map(|ApiJson(v)| v)
93            .map_err(|(status, Json(err))| (status, err))
94    }
95
96    #[tokio::test]
97    async fn valid_body_extracts() {
98        let input = extract(r#"{"name":"abc"}"#, true).await.unwrap();
99        assert_eq!(input.name, "abc");
100    }
101
102    #[tokio::test]
103    async fn malformed_json_is_invalid_json() {
104        let (status, err) = extract("{not json", true).await.unwrap_err();
105        assert_eq!(status, StatusCode::BAD_REQUEST);
106        assert_eq!(err.code, "INVALID_JSON");
107    }
108
109    #[tokio::test]
110    async fn wrong_shape_is_invalid_body() {
111        let (status, err) = extract(r#"{"name":123}"#, true).await.unwrap_err();
112        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
113        assert_eq!(err.code, "INVALID_BODY");
114    }
115
116    #[tokio::test]
117    async fn missing_content_type_is_unsupported_media_type() {
118        let (status, err) = extract(r#"{"name":"abc"}"#, false).await.unwrap_err();
119        assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
120        assert_eq!(err.code, "UNSUPPORTED_MEDIA_TYPE");
121    }
122
123    #[tokio::test]
124    async fn serializes_as_response() {
125        #[derive(Serialize)]
126        struct Out {
127            id: u32,
128        }
129        let res = ApiJson(Out { id: 7 }).into_response();
130        assert_eq!(res.status(), StatusCode::OK);
131        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
132            .await
133            .unwrap();
134        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
135        assert_eq!(v["id"], 7);
136    }
137}