Skip to main content

sova_vld/
validate.rs

1//! `Validate<T>` route attribute, erased hook, and `req.valid()`.
2
3use crate::coerce::coerce_object;
4use crate::ValidationError;
5use sova_core::extend::{named, BoxFuture, MwEntry, RouteTable, RouteValue};
6use sova_core::{App, Request, Response, Router};
7use serde_json::{Map, Value};
8use std::any::type_name;
9use std::borrow::Cow;
10use std::marker::PhantomData;
11use std::sync::Arc;
12use vld::schema::VldParse;
13
14/// Where to read input for [`Validate`].
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ValidateSource {
17    Body,
18    Query,
19    Params,
20    /// Merge: query, then body, then params (params win).
21    All,
22    /// Multipart form (feature `form`).
23    Form,
24}
25
26/// Route attribute: declare the validated DTO for a route.
27pub struct Validate<T> {
28    pub source: ValidateSource,
29    _ty: PhantomData<fn() -> T>,
30}
31
32impl<T> Validate<T> {
33    pub fn body() -> Self {
34        Self {
35            source: ValidateSource::Body,
36            _ty: PhantomData,
37        }
38    }
39    pub fn query() -> Self {
40        Self {
41            source: ValidateSource::Query,
42            _ty: PhantomData,
43        }
44    }
45    pub fn params() -> Self {
46        Self {
47            source: ValidateSource::Params,
48            _ty: PhantomData,
49        }
50    }
51    pub fn all() -> Self {
52        Self {
53            source: ValidateSource::All,
54            _ty: PhantomData,
55        }
56    }
57    pub fn form() -> Self {
58        Self {
59            source: ValidateSource::Form,
60            _ty: PhantomData,
61        }
62    }
63}
64
65impl<T: Send + Sync + 'static> RouteValue for Validate<T> {
66    fn label(&self) -> Cow<'static, str> {
67        let src = match self.source {
68            ValidateSource::Body => "body",
69            ValidateSource::Query => "query",
70            ValidateSource::Params => "params",
71            ValidateSource::All => "all",
72            ValidateSource::Form => "form",
73        };
74        Cow::Owned(format!("Validate<{src}: {}>", type_name::<T>()))
75    }
76}
77
78/// Parsed DTO stored on the request by [`ValidateHook`].
79#[derive(Debug, Clone)]
80pub struct Validated<T>(pub T);
81
82#[allow(clippy::type_complexity, clippy::result_large_err)]
83type HookFn = Arc<dyn Fn(Request) -> BoxFuture<Result<Request, Response>> + Send + Sync>;
84
85/// Type-erased runner invoked by route middleware.
86pub struct ValidateHook {
87    run: HookFn,
88}
89
90impl RouteValue for ValidateHook {
91    fn label(&self) -> Cow<'static, str> {
92        Cow::Borrowed("ValidateHook")
93    }
94}
95
96impl ValidateHook {
97    pub fn wrap<F>(f: F) -> Self
98    where
99        F: Fn(Request) -> BoxFuture<Result<Request, Response>> + Send + Sync + 'static,
100    {
101        Self { run: Arc::new(f) }
102    }
103
104    pub fn body<T: VldParse + Send + Sync + 'static>() -> Self {
105        Self::wrap(|mut req| {
106            Box::pin(async move {
107                let value = read_body_json(&mut req).await.map_err(|e| e.respond(&req))?;
108                finish::<T>(req, value)
109            })
110        })
111    }
112
113    pub fn query<T: VldParse + Send + Sync + 'static>() -> Self {
114        Self::wrap(|req| {
115            Box::pin(async move {
116                let value = read_query_value(&req);
117                finish::<T>(req, value)
118            })
119        })
120    }
121
122    pub fn params<T: VldParse + Send + Sync + 'static>() -> Self {
123        Self::wrap(|req| {
124            Box::pin(async move {
125                let value = read_params_value(&req);
126                finish::<T>(req, value)
127            })
128        })
129    }
130
131    pub fn all<T: VldParse + Send + Sync + 'static>() -> Self {
132        Self::wrap(|mut req| {
133            Box::pin(async move {
134                let query = read_query_value(&req);
135                let body = match read_body_json(&mut req).await {
136                    Ok(b) => b,
137                    Err(_) if matches!(req.method, http::Method::GET | http::Method::HEAD) => {
138                        Value::Object(Map::new())
139                    }
140                    Err(e) => return Err(e.respond(&req)),
141                };
142                let params = read_params_value(&req);
143                finish::<T>(req, merge_objects(query, body, params))
144            })
145        })
146    }
147
148    pub fn form<T: VldParse + Send + Sync + 'static>() -> Self {
149        #[cfg(feature = "form")]
150        {
151            Self::wrap(|mut req| {
152                Box::pin(async move {
153                    let value = read_form_value(&mut req)
154                        .await
155                        .map_err(|e| e.respond(&req))?;
156                    finish::<T>(req, value)
157                })
158            })
159        }
160        #[cfg(not(feature = "form"))]
161        {
162            let _ = type_name::<T>();
163            Self::wrap(|_req| {
164                Box::pin(async move {
165                    Err(Response::text(
166                        "Validate::form requires sova_vld feature `form`",
167                    )
168                    .status(500))
169                })
170            })
171        }
172    }
173
174    pub(crate) fn run(&self, req: Request) -> BoxFuture<Result<Request, Response>> {
175        (self.run)(req)
176    }
177}
178
179#[allow(clippy::result_large_err)]
180fn finish<T: VldParse + Send + Sync + 'static>(
181    mut req: Request,
182    value: Value,
183) -> Result<Request, Response> {
184    match T::vld_parse_value(&value) {
185        Ok(v) => {
186            req.set(Validated(v));
187            Ok(req)
188        }
189        Err(e) => {
190            let err = ValidationError::from(e);
191            #[cfg(feature = "i18n")]
192            let err = crate::i18n_msg::localize(err, &req);
193            Err(err.respond(&req))
194        }
195    }
196}
197
198fn merge_objects(query: Value, body: Value, params: Value) -> Value {
199    let mut map = Map::new();
200    if let Value::Object(q) = query {
201        map.extend(q);
202    }
203    if let Value::Object(b) = body {
204        map.extend(b);
205    }
206    if let Value::Object(p) = params {
207        map.extend(p);
208    }
209    Value::Object(map)
210}
211
212pub(crate) async fn read_body_json(req: &mut Request) -> Result<Value, ValidationError> {
213    let bytes = req.body().await?;
214    if bytes.is_empty() {
215        return Ok(Value::Object(Map::new()));
216    }
217    serde_json::from_slice(&bytes).map_err(|e| {
218        ValidationError(vld::error::VldError::single(
219            vld::error::IssueCode::ParseError,
220            format!("Invalid JSON: {e}"),
221        ))
222    })
223}
224
225pub(crate) fn read_params_value(req: &Request) -> Value {
226    let mut map = Map::new();
227    for (k, v) in &req.params {
228        map.insert(k.clone(), Value::String(v.clone()));
229    }
230    Value::Object(map)
231}
232
233pub(crate) fn read_query_value(req: &Request) -> Value {
234    let raw = req.raw_query();
235    if !raw.is_empty() {
236        // serde_qs cannot deserialize into `Value` at the top level; use Map.
237        if let Ok(map) = serde_qs::from_str::<Map<String, Value>>(raw) {
238            return Value::Object(map);
239        }
240    }
241    let mut map = Map::new();
242    for (k, v) in &req.query {
243        map.insert(k.clone(), Value::String(v.clone()));
244    }
245    Value::Object(map)
246}
247
248#[cfg(feature = "form")]
249pub(crate) async fn read_form_value(req: &mut Request) -> Result<Value, ValidationError> {
250    let data = req.input().await?;
251    let mut map = Map::new();
252    for (name, values) in data.text_map() {
253        match values.as_slice() {
254            [] => {}
255            [one] => {
256                map.insert(name.clone(), Value::String(one.clone()));
257            }
258            many => {
259                map.insert(
260                    name.clone(),
261                    Value::Array(many.iter().cloned().map(Value::String).collect()),
262                );
263            }
264        }
265    }
266    for (name, uploads) in data.file_map() {
267        if let Some(f) = uploads.first() {
268            map.insert(
269                name.clone(),
270                serde_json::json!({
271                    "filename": f.filename,
272                    "content_type": f.content_type,
273                    "size": f.data.len(),
274                }),
275            );
276        }
277    }
278    Ok(Value::Object(map))
279}
280
281#[cfg_attr(not(feature = "openapi"), allow(dead_code))]
282pub(crate) fn coerce_with_schema(value: &mut Value, schema: &Value) {
283    if let Value::Object(map) = value {
284        coerce_object(map, schema);
285    }
286}
287
288/// Access DTO stored by [`ValidateHook`].
289pub trait ValidExt {
290    fn valid<T: Send + Sync + 'static>(&self) -> &T;
291    fn take_valid<T: Send + Sync + 'static>(&mut self) -> Option<T>;
292}
293
294impl ValidExt for Request {
295    fn valid<T: Send + Sync + 'static>(&self) -> &T {
296        self.get::<Validated<T>>()
297            .map(|v| &v.0)
298            .unwrap_or_else(|| {
299                panic!(
300                    "validated `{}` missing — use `.validate_body::<T>()` (etc.) on the route",
301                    type_name::<T>()
302                )
303            })
304    }
305
306    fn take_valid<T: Send + Sync + 'static>(&mut self) -> Option<T> {
307        self.take::<Validated<T>>().map(|v| v.0)
308    }
309}
310
311fn vld_mw() -> MwEntry {
312    named("vld", |req: Request, next: sova_core::Next| async move {
313        if let Some(hook) = req.route_meta::<ValidateHook>() {
314            match hook.run(req).await {
315                Ok(req) => next(req).await,
316                Err(res) => res,
317            }
318        } else {
319            next(req).await
320        }
321    })
322}
323
324#[cfg(feature = "openapi")]
325pub trait ValidateSchema: crate::VldDocSchema {}
326#[cfg(feature = "openapi")]
327impl<T: crate::VldDocSchema> ValidateSchema for T {}
328
329#[cfg(not(feature = "openapi"))]
330pub trait ValidateSchema {}
331#[cfg(not(feature = "openapi"))]
332impl<T> ValidateSchema for T {}
333
334/// Sugar: attach [`Validate`] + [`ValidateHook`] (+ OpenAPI schema when enabled).
335pub trait ValidateRouteExt {
336    fn validate_body<T>(&mut self) -> &mut Self
337    where
338        T: VldParse + Send + Sync + 'static + ValidateSchema;
339    fn validate_query<T>(&mut self) -> &mut Self
340    where
341        T: VldParse + Send + Sync + 'static + ValidateSchema;
342    fn validate_params<T>(&mut self) -> &mut Self
343    where
344        T: VldParse + Send + Sync + 'static + ValidateSchema;
345    fn validate_all<T>(&mut self) -> &mut Self
346    where
347        T: VldParse + Send + Sync + 'static + ValidateSchema;
348    fn validate_form<T>(&mut self) -> &mut Self
349    where
350        T: VldParse + Send + Sync + 'static + ValidateSchema;
351}
352
353fn attach_common<T>(router: &mut Router, validate: Validate<T>, hook: ValidateHook)
354where
355    T: Send + Sync + 'static,
356{
357    router.with(validate);
358    router.with(hook);
359    router.route_middleware(vld_mw());
360}
361
362#[cfg(feature = "openapi")]
363fn attach_openapi(router: &mut Router, source: ValidateSource, schema: Value) {
364    use sova_openapi::OpenApiValidate;
365    router.with_update(|o: &mut OpenApiValidate| match source {
366        ValidateSource::Body | ValidateSource::Form => o.body = Some(schema),
367        ValidateSource::Query => o.query = Some(schema),
368        ValidateSource::Params => o.params = Some(schema),
369        ValidateSource::All => {
370            o.body = Some(schema.clone());
371            o.query = Some(schema.clone());
372            o.params = Some(schema);
373        }
374    });
375}
376
377trait AsRouterMut {
378    fn as_router_mut(&mut self) -> &mut Router;
379}
380impl AsRouterMut for Router {
381    fn as_router_mut(&mut self) -> &mut Router {
382        self
383    }
384}
385impl AsRouterMut for App {
386    fn as_router_mut(&mut self) -> &mut Router {
387        &mut *self
388    }
389}
390
391macro_rules! impl_ext {
392    ($target:ty) => {
393        impl ValidateRouteExt for $target {
394            fn validate_body<T>(&mut self) -> &mut Self
395            where
396                T: VldParse + Send + Sync + 'static + ValidateSchema,
397            {
398                let router = self.as_router_mut();
399                attach_common(router, Validate::<T>::body(), ValidateHook::body::<T>());
400                #[cfg(feature = "openapi")]
401                attach_openapi(router, ValidateSource::Body, T::json_schema());
402                self
403            }
404            fn validate_query<T>(&mut self) -> &mut Self
405            where
406                T: VldParse + Send + Sync + 'static + ValidateSchema,
407            {
408                let router = self.as_router_mut();
409                #[cfg(feature = "openapi")]
410                {
411                    let schema = T::json_schema();
412                    let schema2 = schema.clone();
413                    let hook = ValidateHook::wrap(move |req| {
414                        let schema2 = schema2.clone();
415                        Box::pin(async move {
416                            let mut value = read_query_value(&req);
417                            coerce_with_schema(&mut value, &schema2);
418                            finish::<T>(req, value)
419                        })
420                    });
421                    attach_common(router, Validate::<T>::query(), hook);
422                    attach_openapi(router, ValidateSource::Query, schema);
423                }
424                #[cfg(not(feature = "openapi"))]
425                {
426                    attach_common(router, Validate::<T>::query(), ValidateHook::query::<T>());
427                }
428                self
429            }
430            fn validate_params<T>(&mut self) -> &mut Self
431            where
432                T: VldParse + Send + Sync + 'static + ValidateSchema,
433            {
434                let router = self.as_router_mut();
435                #[cfg(feature = "openapi")]
436                {
437                    let schema = T::json_schema();
438                    let schema2 = schema.clone();
439                    let hook = ValidateHook::wrap(move |req| {
440                        let schema2 = schema2.clone();
441                        Box::pin(async move {
442                            let mut value = read_params_value(&req);
443                            coerce_with_schema(&mut value, &schema2);
444                            finish::<T>(req, value)
445                        })
446                    });
447                    attach_common(router, Validate::<T>::params(), hook);
448                    attach_openapi(router, ValidateSource::Params, schema);
449                }
450                #[cfg(not(feature = "openapi"))]
451                {
452                    attach_common(router, Validate::<T>::params(), ValidateHook::params::<T>());
453                }
454                self
455            }
456            fn validate_all<T>(&mut self) -> &mut Self
457            where
458                T: VldParse + Send + Sync + 'static + ValidateSchema,
459            {
460                let router = self.as_router_mut();
461                #[cfg(feature = "openapi")]
462                {
463                    let schema = T::json_schema();
464                    let schema2 = schema.clone();
465                    let hook = ValidateHook::wrap(move |mut req| {
466                        let schema2 = schema2.clone();
467                        Box::pin(async move {
468                            let mut query = read_query_value(&req);
469                            coerce_with_schema(&mut query, &schema2);
470                            let body = match read_body_json(&mut req).await {
471                                Ok(b) => b,
472                                Err(_)
473                                    if matches!(
474                                        req.method,
475                                        http::Method::GET | http::Method::HEAD
476                                    ) =>
477                                {
478                                    Value::Object(Map::new())
479                                }
480                                Err(e) => return Err(e.respond(&req)),
481                            };
482                            let mut params = read_params_value(&req);
483                            coerce_with_schema(&mut params, &schema2);
484                            finish::<T>(req, merge_objects(query, body, params))
485                        })
486                    });
487                    attach_common(router, Validate::<T>::all(), hook);
488                    attach_openapi(router, ValidateSource::All, schema);
489                }
490                #[cfg(not(feature = "openapi"))]
491                {
492                    attach_common(router, Validate::<T>::all(), ValidateHook::all::<T>());
493                }
494                self
495            }
496            fn validate_form<T>(&mut self) -> &mut Self
497            where
498                T: VldParse + Send + Sync + 'static + ValidateSchema,
499            {
500                let router = self.as_router_mut();
501                attach_common(router, Validate::<T>::form(), ValidateHook::form::<T>());
502                #[cfg(feature = "openapi")]
503                attach_openapi(router, ValidateSource::Form, T::json_schema());
504                self
505            }
506        }
507    };
508}
509
510impl_ext!(Router);
511impl_ext!(App);
512
513/// POST/PUT/PATCH routes that lack a [`ValidateHook`].
514pub fn missing_validate_routes(table: &RouteTable) -> Vec<String> {
515    let mut missing = Vec::new();
516    for entry in &table.0 {
517        let sova_core::extend::RouteEntry::Http {
518            method,
519            path,
520            meta,
521        } = entry
522        else {
523            continue;
524        };
525        if !matches!(
526            *method,
527            http::Method::POST | http::Method::PUT | http::Method::PATCH
528        ) {
529            continue;
530        }
531        if meta.get::<ValidateHook>().is_some() {
532            continue;
533        }
534        missing.push(format!("{method} {path}"));
535    }
536    missing
537}
538
539/// Plugin: coverage check for POST/PUT/PATCH without [`ValidateHook`].
540pub struct Vld;
541
542impl sova_core::Plugin for Vld {
543    fn id(&self) -> &'static str {
544        "vld"
545    }
546
547    fn meta(&self) -> sova_core::PluginMeta {
548        sova_core::PluginMeta::new("Validation")
549            .description("Request validation hooks and coverage check")
550            .version(env!("CARGO_PKG_VERSION"))
551    }
552
553    fn install(self, app: &mut App) {
554        app.register_audit("vld", |state| async move {
555            let Some(table) = state.get::<RouteTable>() else {
556                return Ok(());
557            };
558            let missing = missing_validate_routes(&table);
559            if missing.is_empty() {
560                Ok(())
561            } else {
562                Err(sova_core::Error::Internal(format!(
563                    "POST/PUT/PATCH without Validate: {}",
564                    missing.join(", ")
565                )))
566            }
567        });
568    }
569}