Skip to main content

this/core/validation/
extractor.rs

1//! Axum extractor for validated entities
2//!
3//! This module provides the `Validated<T>` extractor that automatically
4//! validates and filters request payloads before they reach handlers.
5
6use super::config::EntityValidationConfig;
7use axum::{
8    Json,
9    extract::{FromRequest, Request},
10    http::StatusCode,
11    response::{IntoResponse, Response},
12};
13use serde_json::{Value, json};
14
15/// Trait for entities that support validation
16///
17/// This is automatically implemented by the `impl_data_entity_validated!` macro
18pub trait ValidatableEntity {
19    /// Get the validation configuration for a specific operation
20    fn validation_config(operation: &str) -> EntityValidationConfig;
21}
22
23/// Axum extractor that validates and filters entity data
24///
25/// # Usage
26///
27/// ```rust,ignore
28/// pub async fn create_invoice(
29///     Validated::<Invoice>(payload): Validated<Invoice>,
30/// ) -> Result<Json<Invoice>, StatusCode> {
31///     // payload is already validated and filtered!
32/// }
33/// ```
34pub struct Validated<T>(pub Value, std::marker::PhantomData<T>);
35
36impl<T> Validated<T> {
37    /// Create a new validated payload
38    pub fn new(payload: Value) -> Self {
39        Self(payload, std::marker::PhantomData)
40    }
41
42    /// Get the inner payload
43    pub fn into_inner(self) -> Value {
44        self.0
45    }
46}
47
48// Allow dereferencing to Value
49impl<T> std::ops::Deref for Validated<T> {
50    type Target = Value;
51
52    fn deref(&self) -> &Self::Target {
53        &self.0
54    }
55}
56
57impl<S, T> FromRequest<S> for Validated<T>
58where
59    S: Send + Sync,
60    T: ValidatableEntity + Send + Sync,
61{
62    type Rejection = Response;
63
64    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
65        // Extract the HTTP method
66        let method = req.method().clone();
67
68        // Extract JSON payload
69        let Json(payload): Json<Value> = match Json::from_request(req, state).await {
70            Ok(json) => json,
71            Err(e) => {
72                return Err((
73                    StatusCode::BAD_REQUEST,
74                    Json(json!({
75                        "error": "Invalid JSON",
76                        "details": e.to_string()
77                    })),
78                )
79                    .into_response());
80            }
81        };
82
83        // Determine operation from HTTP method
84        let operation = match method.as_str() {
85            "POST" => "create",
86            "PUT" | "PATCH" => "update",
87            _ => "create", // default
88        };
89
90        // Get validation config from entity
91        let config = T::validation_config(operation);
92
93        // Validate and filter
94        match config.validate_and_filter(payload) {
95            Ok(validated_payload) => Ok(Validated::new(validated_payload)),
96            Err(errors) => Err((
97                StatusCode::UNPROCESSABLE_ENTITY,
98                Json(json!({
99                    "error": "Validation failed",
100                    "errors": errors
101                })),
102            )
103                .into_response()),
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use axum::body::Body;
112    use axum::extract::FromRequest;
113    use axum::http::Request;
114    use serde_json::json;
115
116    /// Dummy entity that implements ValidatableEntity for testing.
117    /// - "create" operation: requires "name" (not null), min length 2
118    /// - "update" operation: no validators (everything passes)
119    struct TestEntity;
120
121    impl ValidatableEntity for TestEntity {
122        fn validation_config(operation: &str) -> EntityValidationConfig {
123            let mut config = EntityValidationConfig::new("test_entity");
124            if operation == "create" {
125                config.add_validator("name", |field, value| {
126                    if value.is_null() {
127                        Err(format!("{} is required", field))
128                    } else {
129                        Ok(())
130                    }
131                });
132                config.add_validator("name", |field, value| {
133                    if let Some(s) = value.as_str()
134                        && s.len() < 2
135                    {
136                        return Err(format!("{} too short", field));
137                    }
138                    Ok(())
139                });
140                config.add_filter("name", |_field, value| {
141                    if let Some(s) = value.as_str() {
142                        Ok(Value::String(s.trim().to_string()))
143                    } else {
144                        Ok(value)
145                    }
146                });
147            }
148            config
149        }
150    }
151
152    /// Helper: build an HTTP request with JSON body and given method.
153    fn json_request(method: &str, body: Value) -> Request<Body> {
154        Request::builder()
155            .method(method)
156            .header("content-type", "application/json")
157            .body(Body::from(serde_json::to_vec(&body).unwrap()))
158            .unwrap()
159    }
160
161    // === Validated::new / into_inner / Deref ===
162
163    #[test]
164    fn test_validated_new_and_into_inner() {
165        let val = json!({"name": "test"});
166        let validated = Validated::<TestEntity>::new(val.clone());
167        assert_eq!(validated.into_inner(), val);
168    }
169
170    #[test]
171    fn test_validated_deref() {
172        let val = json!({"key": 42});
173        let validated = Validated::<TestEntity>::new(val);
174        // Deref allows accessing Value methods directly
175        assert_eq!(validated["key"], 42);
176        assert!(validated.is_object());
177    }
178
179    // === FromRequest ===
180
181    #[tokio::test]
182    async fn test_from_request_post_valid_payload() {
183        let req = json_request("POST", json!({"name": "  Alice  "}));
184        let result = Validated::<TestEntity>::from_request(req, &()).await;
185        assert!(result.is_ok());
186        let validated = result.unwrap();
187        // Filter should have trimmed whitespace
188        assert_eq!(validated.0["name"], "Alice");
189    }
190
191    #[tokio::test]
192    async fn test_from_request_post_validation_failure() {
193        // "name" is null → required validator fails
194        let req = json_request("POST", json!({"name": null}));
195        let result = Validated::<TestEntity>::from_request(req, &()).await;
196        assert!(result.is_err());
197        match result {
198            Err(response) => assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY),
199            Ok(_) => panic!("expected error"),
200        }
201    }
202
203    #[tokio::test]
204    async fn test_from_request_post_too_short_after_trim() {
205        // "  a  " → trim → "a" (length 1 < 2) → fails
206        let req = json_request("POST", json!({"name": "  a  "}));
207        let result = Validated::<TestEntity>::from_request(req, &()).await;
208        assert!(result.is_err());
209        match result {
210            Err(response) => assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY),
211            Ok(_) => panic!("expected error"),
212        }
213    }
214
215    #[tokio::test]
216    async fn test_from_request_put_uses_update_operation() {
217        // "update" operation has no validators → everything passes
218        let req = json_request("PUT", json!({"name": null}));
219        let result = Validated::<TestEntity>::from_request(req, &()).await;
220        assert!(result.is_ok());
221    }
222
223    #[tokio::test]
224    async fn test_from_request_patch_uses_update_operation() {
225        let req = json_request("PATCH", json!({"name": null}));
226        let result = Validated::<TestEntity>::from_request(req, &()).await;
227        assert!(result.is_ok());
228    }
229
230    #[tokio::test]
231    async fn test_from_request_get_defaults_to_create_operation() {
232        // GET defaults to "create" operation → name=null fails validation
233        let req = json_request("GET", json!({"name": null}));
234        let result = Validated::<TestEntity>::from_request(req, &()).await;
235        assert!(result.is_err());
236    }
237
238    #[tokio::test]
239    async fn test_from_request_invalid_json_returns_400() {
240        let req = Request::builder()
241            .method("POST")
242            .header("content-type", "application/json")
243            .body(Body::from("not valid json {{{"))
244            .unwrap();
245        let result = Validated::<TestEntity>::from_request(req, &()).await;
246        match result {
247            Err(response) => assert_eq!(response.status(), StatusCode::BAD_REQUEST),
248            Ok(_) => panic!("expected error"),
249        }
250    }
251
252    #[tokio::test]
253    async fn test_from_request_missing_content_type_returns_400() {
254        let req = Request::builder()
255            .method("POST")
256            .body(Body::from(r#"{"name": "test"}"#))
257            .unwrap();
258        let result = Validated::<TestEntity>::from_request(req, &()).await;
259        match result {
260            Err(response) => assert_eq!(response.status(), StatusCode::BAD_REQUEST),
261            Ok(_) => panic!("expected error"),
262        }
263    }
264}