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