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}