Skip to main content

vld_warp/
lib.rs

1//! # vld-warp — Warp integration for `vld`
2//!
3//! Validation [filters](warp::Filter) for [Warp](https://docs.rs/warp).
4//! Validates request data against `vld` schemas and rejects with structured
5//! JSON errors on failure.
6//!
7//! # Filters
8//!
9//! | Filter / Function | Source |
10//! |-------------------|--------|
11//! | [`vld_json::<T>()`] | JSON body |
12//! | [`vld_query::<T>()`] | Query string |
13//! | [`vld_form::<T>()`] | URL-encoded form body |
14//! | [`vld_param::<T>(name)`] | Single path segment |
15//! | [`vld_path::<T>(names)`] | All remaining path segments (tail) |
16//! | [`validate_path_params::<T>(pairs)`] | Pre-extracted path params |
17//! | [`vld_headers::<T>()`] | HTTP headers |
18//! | [`vld_cookie::<T>()`] | Cookie values |
19
20use std::convert::Infallible;
21use vld::schema::VldParse;
22use warp::http::StatusCode;
23use warp::reject::Reject;
24use warp::{Filter, Rejection, Reply};
25
26// ---------------------------------------------------------------------------
27// Rejection types
28// ---------------------------------------------------------------------------
29
30/// Rejection when JSON parsing fails.
31#[derive(Debug)]
32pub struct InvalidJson {
33    pub message: String,
34}
35impl Reject for InvalidJson {}
36
37/// Rejection when vld validation fails.
38#[derive(Debug)]
39pub struct ValidationFailed {
40    pub error: vld::error::VldError,
41}
42impl Reject for ValidationFailed {}
43
44// ---------------------------------------------------------------------------
45// vld_json filter
46// ---------------------------------------------------------------------------
47
48/// Warp filter that extracts and validates a JSON body.
49///
50/// Returns the validated `T` or rejects with [`ValidationFailed`].
51///
52/// # Example
53///
54/// ```rust,ignore
55/// use vld_warp::vld_json;
56///
57/// vld::schema! {
58///     #[derive(Debug, Clone)]
59///     pub struct CreateUser {
60///         pub name: String  => vld::string().min(2),
61///         pub email: String => vld::string().email(),
62///     }
63/// }
64///
65/// let route = warp::post()
66///     .and(warp::path("users"))
67///     .and(vld_json::<CreateUser>())
68///     .map(|user: CreateUser| {
69///         warp::reply::json(&serde_json::json!({"name": user.name}))
70///     });
71/// ```
72pub fn vld_json<T: VldParse + Send + 'static>(
73) -> impl Filter<Extract = (T,), Error = Rejection> + Clone {
74    warp::body::bytes().and_then(|bytes: bytes::Bytes| async move {
75        let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
76            warp::reject::custom(InvalidJson {
77                message: e.to_string(),
78            })
79        })?;
80
81        T::vld_parse_value(&value).map_err(|e| warp::reject::custom(ValidationFailed { error: e }))
82    })
83}
84
85// ---------------------------------------------------------------------------
86// vld_query filter
87// ---------------------------------------------------------------------------
88
89/// Warp filter that extracts and validates query parameters.
90///
91/// Parses the query string into a JSON object with value coercion,
92/// then validates via `T::vld_parse_value()`.
93///
94/// # Example
95///
96/// ```rust,ignore
97/// use vld_warp::vld_query;
98///
99/// vld::schema! {
100///     #[derive(Debug, Clone)]
101///     pub struct Pagination {
102///         pub page: i64  => vld::number().int().min(1),
103///         pub limit: i64 => vld::number().int().min(1).max(100),
104///     }
105/// }
106///
107/// let route = warp::get()
108///     .and(warp::path("items"))
109///     .and(vld_query::<Pagination>())
110///     .map(|p: Pagination| {
111///         warp::reply::json(&serde_json::json!({"page": p.page, "limit": p.limit}))
112///     });
113/// ```
114pub fn vld_query<T: VldParse + Send + 'static>(
115) -> impl Filter<Extract = (T,), Error = Rejection> + Clone {
116    warp::query::raw()
117        .or(warp::any().map(String::new))
118        .unify()
119        .and_then(|qs: String| async move {
120            let map = parse_query_to_json(&qs);
121            let value = serde_json::Value::Object(map);
122            T::vld_parse_value(&value)
123                .map_err(|e| warp::reject::custom(ValidationFailed { error: e }))
124        })
125}
126
127// ---------------------------------------------------------------------------
128// Recovery handler
129// ---------------------------------------------------------------------------
130
131/// Recovery handler that converts vld rejections into JSON responses.
132///
133/// Use with `warp::Filter::recover()`:
134///
135/// ```rust,ignore
136/// use vld_warp::handle_rejection;
137///
138/// let routes = warp::any()
139///     // ...your routes...
140///     .recover(handle_rejection);
141/// ```
142pub async fn handle_rejection(err: Rejection) -> Result<impl Reply, Infallible> {
143    if let Some(e) = err.find::<ValidationFailed>() {
144        let body = vld_http_common::format_vld_error(&e.error);
145        let reply =
146            warp::reply::with_status(warp::reply::json(&body), StatusCode::UNPROCESSABLE_ENTITY);
147        return Ok(reply);
148    }
149
150    if let Some(e) = err.find::<InvalidJson>() {
151        let body = vld_http_common::format_json_parse_error(&e.message);
152        let reply = warp::reply::with_status(warp::reply::json(&body), StatusCode::BAD_REQUEST);
153        return Ok(reply);
154    }
155
156    let body = vld_http_common::format_generic_error("Not Found");
157    let reply = warp::reply::with_status(warp::reply::json(&body), StatusCode::NOT_FOUND);
158    Ok(reply)
159}
160
161// ---------------------------------------------------------------------------
162// Helpers
163// ---------------------------------------------------------------------------
164
165use vld_http_common::{coerce_value, cookies_to_json, parse_query_string as parse_query_to_json};
166
167// ---------------------------------------------------------------------------
168// vld_form filter
169// ---------------------------------------------------------------------------
170
171/// Warp filter that extracts and validates a URL-encoded form body.
172///
173/// Values are coerced: `"42"` → number, `"true"` → bool, empty → null.
174pub fn vld_form<T: VldParse + Send + 'static>(
175) -> impl Filter<Extract = (T,), Error = Rejection> + Clone {
176    warp::body::bytes().and_then(|bytes: bytes::Bytes| async move {
177        let body_str = std::str::from_utf8(&bytes).map_err(|_| {
178            warp::reject::custom(InvalidJson {
179                message: "Form body is not valid UTF-8".into(),
180            })
181        })?;
182
183        let map = parse_query_to_json(body_str);
184        let value = serde_json::Value::Object(map);
185
186        T::vld_parse_value(&value).map_err(|e| warp::reject::custom(ValidationFailed { error: e }))
187    })
188}
189
190// ---------------------------------------------------------------------------
191// vld_param — single path parameter filter
192// ---------------------------------------------------------------------------
193
194/// Warp filter that extracts and validates **a single path segment**.
195///
196/// Works like `warp::path::param::<String>()` but coerces the raw string
197/// value (numbers, booleans, null) and validates via `T::vld_parse_value()`.
198///
199/// The extracted segment is wrapped into a JSON object `{ "<name>": <coerced> }`
200/// so that the vld schema field name matches.
201///
202/// # Example
203///
204/// ```rust,ignore
205/// use vld_warp::vld_param;
206///
207/// vld::schema! {
208///     #[derive(Debug, Clone)]
209///     pub struct UserId {
210///         pub id: i64 => vld::number().int().min(1),
211///     }
212/// }
213///
214/// // GET /users/<id>
215/// let route = warp::path("users")
216///     .and(vld_param::<UserId>("id"))
217///     .and(warp::path::end())
218///     .map(|p: UserId| {
219///         warp::reply::json(&serde_json::json!({"id": p.id}))
220///     });
221/// ```
222pub fn vld_param<T: VldParse + Send + 'static>(
223    name: &'static str,
224) -> impl Filter<Extract = (T,), Error = Rejection> + Clone {
225    warp::path::param::<String>().and_then(move |raw: String| async move {
226        let mut map = serde_json::Map::new();
227        map.insert(name.to_string(), coerce_value(&raw));
228        let value = serde_json::Value::Object(map);
229        T::vld_parse_value(&value).map_err(|e| warp::reject::custom(ValidationFailed { error: e }))
230    })
231}
232
233// ---------------------------------------------------------------------------
234// vld_path — multi-param tail filter
235// ---------------------------------------------------------------------------
236
237/// Warp filter that extracts and validates **all remaining path segments**.
238///
239/// Uses `warp::path::tail()` internally — all segments after the current
240/// position are consumed and mapped to `param_names` in order.
241/// The number of remaining segments **must equal** the number of names;
242/// otherwise the request is rejected with 404 (not found).
243///
244/// Best suited for routes where all remaining segments are parameters
245/// (no static segments left).
246///
247/// # Example
248///
249/// ```rust,ignore
250/// use vld_warp::vld_path;
251///
252/// vld::schema! {
253///     #[derive(Debug, Clone)]
254///     pub struct PostPath {
255///         pub user_id: i64 => vld::number().int().min(1),
256///         pub post_id: i64 => vld::number().int().min(1),
257///     }
258/// }
259///
260/// // GET /posts/<user_id>/<post_id>
261/// let route = warp::path("posts")
262///     .and(vld_path::<PostPath>(&["user_id", "post_id"]))
263///     .map(|p: PostPath| {
264///         warp::reply::json(&serde_json::json!({
265///             "user_id": p.user_id,
266///             "post_id": p.post_id
267///         }))
268///     });
269/// ```
270pub fn vld_path<T: VldParse + Send + 'static>(
271    param_names: &'static [&'static str],
272) -> impl Filter<Extract = (T,), Error = Rejection> + Clone {
273    warp::path::tail().and_then(move |tail: warp::path::Tail| async move {
274        let segments: Vec<&str> = tail.as_str().split('/').filter(|s| !s.is_empty()).collect();
275
276        if segments.len() != param_names.len() {
277            return Err(warp::reject::not_found());
278        }
279
280        let mut map = serde_json::Map::new();
281        for (name, raw) in param_names.iter().zip(segments.iter()) {
282            map.insert(name.to_string(), coerce_value(raw));
283        }
284        let value = serde_json::Value::Object(map);
285        T::vld_parse_value(&value).map_err(|e| warp::reject::custom(ValidationFailed { error: e }))
286    })
287}
288
289// ---------------------------------------------------------------------------
290// validate_path_params — standalone validator for pre-extracted params
291// ---------------------------------------------------------------------------
292
293/// Validate pre-extracted path parameter pairs against a vld schema.
294///
295/// Useful for complex routes where static and dynamic segments are
296/// interleaved and you extract `String` params with
297/// `warp::path::param::<String>()`, then validate them all at once.
298///
299/// # Example
300///
301/// ```rust,ignore
302/// use vld_warp::validate_path_params;
303///
304/// vld::schema! {
305///     #[derive(Debug, Clone)]
306///     pub struct CommentPath {
307///         pub user_id: i64 => vld::number().int().min(1),
308///         pub post_id: i64 => vld::number().int().min(1),
309///         pub comment_id: i64 => vld::number().int().min(1),
310///     }
311/// }
312///
313/// // GET /users/<id>/posts/<pid>/comments/<cid>
314/// let route = warp::path("users")
315///     .and(warp::path::param::<String>())
316///     .and(warp::path("posts"))
317///     .and(warp::path::param::<String>())
318///     .and(warp::path("comments"))
319///     .and(warp::path::param::<String>())
320///     .and(warp::path::end())
321///     .and_then(|uid: String, pid: String, cid: String| async move {
322///         validate_path_params::<CommentPath>(&[
323///             ("user_id", &uid),
324///             ("post_id", &pid),
325///             ("comment_id", &cid),
326///         ])
327///     });
328/// ```
329pub fn validate_path_params<T: VldParse>(params: &[(&str, &str)]) -> Result<T, Rejection> {
330    let mut map = serde_json::Map::new();
331    for (name, raw) in params {
332        map.insert(name.to_string(), coerce_value(raw));
333    }
334    let value = serde_json::Value::Object(map);
335    T::vld_parse_value(&value).map_err(|e| warp::reject::custom(ValidationFailed { error: e }))
336}
337
338// ---------------------------------------------------------------------------
339// vld_headers filter
340// ---------------------------------------------------------------------------
341
342/// Warp filter that extracts and validates HTTP headers.
343///
344/// Header names are normalised to snake_case: `Content-Type` → `content_type`.
345/// Values are coerced: `"42"` → number, `"true"` → bool, etc.
346pub fn vld_headers<T: VldParse + Send + 'static>(
347) -> impl Filter<Extract = (T,), Error = Rejection> + Clone {
348    warp::header::headers_cloned().and_then(|headers: warp::http::HeaderMap| async move {
349        let mut map = serde_json::Map::new();
350        for (name, value) in headers.iter() {
351            let key = name.as_str().to_lowercase().replace('-', "_");
352            if let Ok(v) = value.to_str() {
353                map.insert(key, coerce_value(v));
354            }
355        }
356        let value = serde_json::Value::Object(map);
357
358        T::vld_parse_value(&value).map_err(|e| warp::reject::custom(ValidationFailed { error: e }))
359    })
360}
361
362// ---------------------------------------------------------------------------
363// vld_cookie filter
364// ---------------------------------------------------------------------------
365
366/// Warp filter that extracts and validates cookies from the `Cookie` header.
367///
368/// Cookie names are used as-is for schema field matching.
369/// Values are coerced: `"42"` → number, `"true"` → bool, etc.
370pub fn vld_cookie<T: VldParse + Send + 'static>(
371) -> impl Filter<Extract = (T,), Error = Rejection> + Clone {
372    warp::header::optional::<String>("cookie").and_then(
373        |cookie_header: Option<String>| async move {
374            let value = cookies_to_json(cookie_header.as_deref().unwrap_or(""));
375
376            T::vld_parse_value(&value)
377                .map_err(|e| warp::reject::custom(ValidationFailed { error: e }))
378        },
379    )
380}
381
382/// Prelude — import everything you need.
383pub mod prelude {
384    pub use crate::{
385        handle_rejection, validate_path_params, vld_cookie, vld_form, vld_headers, vld_json,
386        vld_param, vld_path, vld_query, InvalidJson, ValidationFailed,
387    };
388    pub use vld::prelude::*;
389}