1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ValidateSource {
17 Body,
18 Query,
19 Params,
20 All,
22 Form,
24}
25
26pub 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#[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
85pub 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 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
288pub 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
334pub 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
513pub 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
539pub 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}