Skip to main content

vld_actix/
lib.rs

1//! # vld-actix — Actix-web integration for the `vld` validation library
2//!
3//! Provides extractors that validate request data using `vld` schemas:
4//!
5//! | Extractor | Replaces | Source |
6//! |---|---|---|
7//! | [`VldJson<T>`] | `actix_web::web::Json<T>` | JSON request body |
8//! | [`VldQuery<T>`] | `actix_web::web::Query<T>` | URL query parameters |
9//! | [`VldPath<T>`] | `actix_web::web::Path<T>` | URL path parameters |
10//! | [`VldForm<T>`] | `actix_web::web::Form<T>` | URL-encoded form body |
11//! | [`VldHeaders<T>`] | manual header extraction | HTTP headers |
12//! | [`VldCookie<T>`] | manual cookie parsing | Cookie values |
13//!
14//! All extractors return **422 Unprocessable Entity** on validation failure.
15//!
16//! # Quick example
17//!
18//! ```ignore
19//! use actix_web::{web, App, HttpResponse};
20//! use vld::prelude::*;
21//! use vld_actix::{VldPath, VldQuery, VldJson, VldHeaders};
22//!
23//! vld::schema! {
24//!     #[derive(Debug)]
25//!     pub struct PathParams {
26//!         pub id: i64 => vld::number().int().min(1),
27//!     }
28//! }
29//!
30//! vld::schema! {
31//!     #[derive(Debug)]
32//!     pub struct Auth {
33//!         pub authorization: String => vld::string().min(1),
34//!     }
35//! }
36//!
37//! vld::schema! {
38//!     #[derive(Debug)]
39//!     pub struct Body {
40//!         pub name: String => vld::string().min(2),
41//!     }
42//! }
43//!
44//! async fn handler(
45//!     path: VldPath<PathParams>,
46//!     headers: VldHeaders<Auth>,
47//!     body: VldJson<Body>,
48//! ) -> HttpResponse {
49//!     HttpResponse::Ok().body(format!(
50//!         "id={} auth={} name={}",
51//!         path.id, headers.authorization, body.name,
52//!     ))
53//! }
54//! ```
55
56use actix_web::dev::Payload;
57use actix_web::{FromRequest, HttpRequest, HttpResponse, ResponseError};
58use std::fmt;
59use std::future::Future;
60use std::pin::Pin;
61
62// ============================= Error / Rejection =============================
63
64/// Error type returned when validation fails.
65///
66/// Used by all `Vld*` extractors in this crate.
67#[derive(Debug)]
68pub struct VldJsonError {
69    error: vld::error::VldError,
70}
71
72impl fmt::Display for VldJsonError {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        write!(f, "Validation failed: {}", self.error)
75    }
76}
77
78impl ResponseError for VldJsonError {
79    fn error_response(&self) -> HttpResponse {
80        let errors: Vec<serde_json::Value> = self
81            .error
82            .issues
83            .iter()
84            .map(|issue| {
85                let path: String = issue.path.iter().map(|p| p.to_string()).collect();
86                serde_json::json!({
87                    "path": path,
88                    "message": issue.message,
89                    "code": issue.code.key(),
90                })
91            })
92            .collect();
93
94        let body = serde_json::json!({
95            "error": "Validation failed",
96            "issues": errors,
97        });
98
99        HttpResponse::UnprocessableEntity()
100            .content_type("application/json")
101            .body(body.to_string())
102    }
103}
104
105// ============================= VldJson =======================================
106
107/// Actix-web extractor that validates **JSON request bodies**.
108///
109/// Drop-in replacement for `actix_web::web::Json<T>`.
110pub struct VldJson<T>(pub T);
111
112impl<T> std::ops::Deref for VldJson<T> {
113    type Target = T;
114    fn deref(&self) -> &T {
115        &self.0
116    }
117}
118
119impl<T> std::ops::DerefMut for VldJson<T> {
120    fn deref_mut(&mut self) -> &mut T {
121        &mut self.0
122    }
123}
124
125impl<T: vld::schema::VldParse> FromRequest for VldJson<T> {
126    type Error = VldJsonError;
127    type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
128
129    fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
130        let json_fut = actix_web::web::Json::<serde_json::Value>::from_request(req, payload);
131
132        Box::pin(async move {
133            let json_value = json_fut.await.map_err(|e| VldJsonError {
134                error: vld::error::VldError::single(
135                    vld::error::IssueCode::ParseError,
136                    format!("JSON parse error: {}", e),
137                ),
138            })?;
139
140            let parsed = T::vld_parse_value(&json_value).map_err(|error| VldJsonError { error })?;
141
142            Ok(VldJson(parsed))
143        })
144    }
145}
146
147// ============================= VldQuery ======================================
148
149/// Actix-web extractor that validates **URL query parameters**.
150///
151/// Drop-in replacement for `actix_web::web::Query<T>`.
152///
153/// Values are coerced: `"42"` → number, `"true"`/`"false"` → boolean, empty → null.
154pub struct VldQuery<T>(pub T);
155
156impl<T> std::ops::Deref for VldQuery<T> {
157    type Target = T;
158    fn deref(&self) -> &T {
159        &self.0
160    }
161}
162
163impl<T> std::ops::DerefMut for VldQuery<T> {
164    fn deref_mut(&mut self) -> &mut T {
165        &mut self.0
166    }
167}
168
169impl<T: vld::schema::VldParse> FromRequest for VldQuery<T> {
170    type Error = VldJsonError;
171    type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
172
173    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
174        let query_string = req.query_string().to_owned();
175
176        Box::pin(async move {
177            let value = query_string_to_json(&query_string);
178
179            let parsed = T::vld_parse_value(&value).map_err(|error| VldJsonError { error })?;
180
181            Ok(VldQuery(parsed))
182        })
183    }
184}
185
186// ============================= VldPath =======================================
187
188/// Actix-web extractor that validates **URL path parameters**.
189///
190/// Drop-in replacement for `actix_web::web::Path<T>`.
191///
192/// Path segment values are coerced the same way as query parameters.
193///
194/// # Example
195///
196/// ```ignore
197/// // Route: /users/{id}/posts/{post_id}
198/// vld::schema! {
199///     #[derive(Debug)]
200///     pub struct PostPath {
201///         pub id: i64 => vld::number().int().min(1),
202///         pub post_id: i64 => vld::number().int().min(1),
203///     }
204/// }
205///
206/// async fn get_post(path: VldPath<PostPath>) -> HttpResponse {
207///     HttpResponse::Ok().body(format!("user {} post {}", path.id, path.post_id))
208/// }
209/// ```
210pub struct VldPath<T>(pub T);
211
212impl<T> std::ops::Deref for VldPath<T> {
213    type Target = T;
214    fn deref(&self) -> &T {
215        &self.0
216    }
217}
218
219impl<T> std::ops::DerefMut for VldPath<T> {
220    fn deref_mut(&mut self) -> &mut T {
221        &mut self.0
222    }
223}
224
225impl<T: vld::schema::VldParse> FromRequest for VldPath<T> {
226    type Error = VldJsonError;
227    type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
228
229    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
230        let mut map = serde_json::Map::new();
231
232        // Extract param names from the matched route pattern, e.g. "/users/{id}"
233        if let Some(pattern) = req.match_pattern() {
234            for name in extract_path_param_names(&pattern) {
235                if let Some(value) = req.match_info().get(&name) {
236                    map.insert(name, coerce_value(value));
237                }
238            }
239        }
240
241        let value = serde_json::Value::Object(map);
242
243        Box::pin(async move {
244            let parsed = T::vld_parse_value(&value).map_err(|error| VldJsonError { error })?;
245
246            Ok(VldPath(parsed))
247        })
248    }
249}
250
251// ============================= VldForm =======================================
252
253/// Actix-web extractor that validates **URL-encoded form bodies**
254/// (`application/x-www-form-urlencoded`).
255///
256/// Drop-in replacement for `actix_web::web::Form<T>`.
257///
258/// Values are coerced the same way as query parameters.
259///
260/// # Example
261///
262/// ```ignore
263/// vld::schema! {
264///     #[derive(Debug)]
265///     pub struct LoginForm {
266///         pub username: String => vld::string().min(3).max(50),
267///         pub password: String => vld::string().min(8),
268///     }
269/// }
270///
271/// async fn login(form: VldForm<LoginForm>) -> HttpResponse {
272///     HttpResponse::Ok().body(format!("Welcome, {}!", form.username))
273/// }
274/// ```
275pub struct VldForm<T>(pub T);
276
277impl<T> std::ops::Deref for VldForm<T> {
278    type Target = T;
279    fn deref(&self) -> &T {
280        &self.0
281    }
282}
283
284impl<T> std::ops::DerefMut for VldForm<T> {
285    fn deref_mut(&mut self) -> &mut T {
286        &mut self.0
287    }
288}
289
290impl<T: vld::schema::VldParse> FromRequest for VldForm<T> {
291    type Error = VldJsonError;
292    type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
293
294    fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
295        let bytes_fut = actix_web::web::Bytes::from_request(req, payload);
296
297        Box::pin(async move {
298            let body = bytes_fut.await.map_err(|e| VldJsonError {
299                error: vld::error::VldError::single(
300                    vld::error::IssueCode::ParseError,
301                    format!("Failed to read form body: {}", e),
302                ),
303            })?;
304
305            let body_str = std::str::from_utf8(&body).map_err(|_| VldJsonError {
306                error: vld::error::VldError::single(
307                    vld::error::IssueCode::ParseError,
308                    "Form body is not valid UTF-8",
309                ),
310            })?;
311
312            let value = query_string_to_json(body_str);
313
314            let parsed = T::vld_parse_value(&value).map_err(|error| VldJsonError { error })?;
315
316            Ok(VldForm(parsed))
317        })
318    }
319}
320
321// ============================= VldHeaders ====================================
322
323/// Actix-web extractor that validates **HTTP headers**.
324///
325/// Header names are normalised to snake_case for schema matching:
326/// `Content-Type` → `content_type`, `X-Request-Id` → `x_request_id`.
327///
328/// Values are coerced: `"42"` → number, `"true"` → boolean, etc.
329///
330/// # Example
331///
332/// ```ignore
333/// vld::schema! {
334///     #[derive(Debug)]
335///     pub struct RequiredHeaders {
336///         pub authorization: String => vld::string().min(1),
337///         pub x_request_id: Option<String> => vld::string().uuid().optional(),
338///     }
339/// }
340///
341/// async fn handler(headers: VldHeaders<RequiredHeaders>) -> HttpResponse {
342///     HttpResponse::Ok().body(format!("auth={}", headers.authorization))
343/// }
344/// ```
345pub struct VldHeaders<T>(pub T);
346
347impl<T> std::ops::Deref for VldHeaders<T> {
348    type Target = T;
349    fn deref(&self) -> &T {
350        &self.0
351    }
352}
353
354impl<T> std::ops::DerefMut for VldHeaders<T> {
355    fn deref_mut(&mut self) -> &mut T {
356        &mut self.0
357    }
358}
359
360impl<T: vld::schema::VldParse> FromRequest for VldHeaders<T> {
361    type Error = VldJsonError;
362    type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
363
364    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
365        let value = headers_to_json(req.headers());
366
367        Box::pin(async move {
368            let parsed = T::vld_parse_value(&value).map_err(|error| VldJsonError { error })?;
369
370            Ok(VldHeaders(parsed))
371        })
372    }
373}
374
375// ============================= VldCookie =====================================
376
377/// Actix-web extractor that validates **cookie values** from the `Cookie` header.
378///
379/// Cookie names are used as-is for schema field matching.
380/// Values are coerced the same way as query parameters.
381///
382/// # Example
383///
384/// ```ignore
385/// vld::schema! {
386///     #[derive(Debug)]
387///     pub struct SessionCookies {
388///         pub session_id: String => vld::string().min(1),
389///         pub theme: Option<String> => vld::string().optional(),
390///     }
391/// }
392///
393/// async fn dashboard(cookies: VldCookie<SessionCookies>) -> HttpResponse {
394///     HttpResponse::Ok().body(format!("session={}", cookies.session_id))
395/// }
396/// ```
397pub struct VldCookie<T>(pub T);
398
399impl<T> std::ops::Deref for VldCookie<T> {
400    type Target = T;
401    fn deref(&self) -> &T {
402        &self.0
403    }
404}
405
406impl<T> std::ops::DerefMut for VldCookie<T> {
407    fn deref_mut(&mut self) -> &mut T {
408        &mut self.0
409    }
410}
411
412impl<T: vld::schema::VldParse> FromRequest for VldCookie<T> {
413    type Error = VldJsonError;
414    type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
415
416    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
417        let cookie_header = req
418            .headers()
419            .get(actix_web::http::header::COOKIE)
420            .and_then(|v| v.to_str().ok())
421            .unwrap_or("")
422            .to_owned();
423
424        Box::pin(async move {
425            let value = cookies_to_json(&cookie_header);
426
427            let parsed = T::vld_parse_value(&value).map_err(|error| VldJsonError { error })?;
428
429            Ok(VldCookie(parsed))
430        })
431    }
432}
433
434// ========================= Helper functions ==================================
435
436use vld_http_common::{
437    coerce_value, cookies_to_json, extract_path_param_names, query_string_to_json,
438};
439
440/// Build a JSON object from HTTP headers.
441///
442/// Header names are normalised: `content-type` → `content_type`.
443fn headers_to_json(headers: &actix_web::http::header::HeaderMap) -> serde_json::Value {
444    let mut map = serde_json::Map::new();
445
446    for (name, value) in headers.iter() {
447        let key = name.as_str().replace('-', "_");
448        if let Ok(v) = value.to_str() {
449            map.insert(key, coerce_value(v));
450        }
451    }
452
453    serde_json::Value::Object(map)
454}
455
456/// Prelude — import everything you need.
457pub mod prelude {
458    pub use crate::{VldCookie, VldForm, VldHeaders, VldJson, VldJsonError, VldPath, VldQuery};
459    pub use vld::prelude::*;
460}