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}