Skip to main content

axum_api_kit/
validated.rs

1use axum::{
2    extract::{FromRequest, Request},
3    http::StatusCode,
4    Json,
5};
6use serde::de::DeserializeOwned;
7use validator::Validate;
8
9use crate::error::json_rejection_to_api_error;
10use crate::ApiError;
11
12/// An Axum extractor that deserializes a JSON request body and validates it with
13/// [`validator`](https://docs.rs/validator).
14///
15/// On success it yields `ValidatedJson(value)`. On failure it short-circuits the handler
16/// with a `(StatusCode, Json<ApiError>)` response:
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/// - validation failure -> `422 Unprocessable Entity`, code `VALIDATION_ERROR` with
23///   field-level `details` (see [`ApiError`]'s `From<validator::ValidationErrors>` impl)
24///
25/// Requires the `validator` feature.
26///
27/// # Example
28///
29/// ```rust,no_run
30/// use axum_api_kit::ValidatedJson;
31/// use serde::Deserialize;
32/// use validator::Validate;
33///
34/// #[derive(Deserialize, Validate)]
35/// struct CreateUser {
36///     #[validate(length(min = 1, max = 100))]
37///     name: String,
38///     #[validate(email)]
39///     email: String,
40/// }
41///
42/// // The body is deserialized and validated before the handler body runs.
43/// async fn create_user(ValidatedJson(user): ValidatedJson<CreateUser>) {
44///     let _ = (user.name, user.email);
45/// }
46/// ```
47#[derive(Debug, Clone)]
48pub struct ValidatedJson<T>(pub T);
49
50impl<T, S> FromRequest<S> for ValidatedJson<T>
51where
52    T: DeserializeOwned + Validate,
53    S: Send + Sync,
54{
55    type Rejection = (StatusCode, Json<ApiError>);
56
57    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
58        let Json(value) = Json::<T>::from_request(req, state)
59            .await
60            .map_err(json_rejection_to_api_error)?;
61
62        value.validate().map_err(|errors| {
63            (
64                StatusCode::UNPROCESSABLE_ENTITY,
65                Json(ApiError::from(errors)),
66            )
67        })?;
68
69        Ok(ValidatedJson(value))
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, Validate)]
80    struct Input {
81        #[validate(length(min = 2))]
82        name: String,
83    }
84
85    async fn extract(body: &str, with_content_type: bool) -> Result<Input, (StatusCode, ApiError)> {
86        let mut builder = Request::builder().method("POST").uri("/");
87        if with_content_type {
88            builder = builder.header(CONTENT_TYPE, "application/json");
89        }
90        let req = builder.body(Body::from(body.to_owned())).unwrap();
91        ValidatedJson::<Input>::from_request(req, &())
92            .await
93            .map(|ValidatedJson(v)| v)
94            .map_err(|(status, Json(err))| (status, err))
95    }
96
97    #[tokio::test]
98    async fn valid_body_extracts() {
99        let input = extract(r#"{"name":"abcd"}"#, true).await.unwrap();
100        assert_eq!(input.name, "abcd");
101    }
102
103    #[tokio::test]
104    async fn malformed_json_is_invalid_json() {
105        let (status, err) = extract("{not json", true).await.unwrap_err();
106        assert_eq!(status, StatusCode::BAD_REQUEST);
107        assert_eq!(err.code, "INVALID_JSON");
108    }
109
110    #[tokio::test]
111    async fn wrong_shape_is_invalid_body() {
112        let (status, err) = extract(r#"{"name":123}"#, true).await.unwrap_err();
113        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
114        assert_eq!(err.code, "INVALID_BODY");
115    }
116
117    #[tokio::test]
118    async fn missing_content_type_is_unsupported_media_type() {
119        let (status, err) = extract(r#"{"name":"abcd"}"#, false).await.unwrap_err();
120        assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
121        assert_eq!(err.code, "UNSUPPORTED_MEDIA_TYPE");
122    }
123
124    #[tokio::test]
125    async fn validation_failure_is_validation_error_with_fields() {
126        let (status, err) = extract(r#"{"name":"a"}"#, true).await.unwrap_err();
127        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
128        assert_eq!(err.code, "VALIDATION_ERROR");
129        let v = serde_json::to_value(&err).unwrap();
130        assert!(v["details"]["fields"]["name"].is_array());
131    }
132}