1use axum::body::Bytes;
2use std::collections::HashMap;
3
4use axum::extract::multipart::MultipartError;
5use axum::extract::{FromRequest, Multipart, Request};
6use axum::http::header::CONTENT_TYPE;
7use axum::http::{Method, StatusCode};
8use axum::response::{IntoResponse, Response};
9use serde::de::DeserializeOwned;
10use serde_json::{Map, Value};
11
12use super::messages::render;
13use super::nested;
14use super::{Errors, Validate, ValidationError, Validator};
15use crate::upload::{self, Upload};
16use crate::{AppState, Error};
17
18pub struct Valid<T>(pub T);
30
31pub(crate) struct Messages {
33 pub(crate) texts: crate::i18n::Texts,
34}
35
36impl Messages {
37 fn template(&self, key: &str) -> std::borrow::Cow<'static, str> {
38 super::messages::template_for(Some(&self.texts), key)
39 }
40
41 fn label(&self, field: &str) -> String {
42 nested::label(field, |key| {
43 self.texts
44 .get(&format!("renox.validation.attributes.{key}"))
45 .cloned()
46 })
47 }
48}
49
50pub(crate) enum Parsed<T> {
51 Ok(T, Errors),
54 Invalid(Errors),
55}
56
57impl<T> FromRequest<AppState> for Valid<T>
58where
59 T: DeserializeOwned + Validate + Send,
60{
61 type Rejection = Response;
62
63 async fn from_request(req: Request, state: &AppState) -> Result<Self, Response> {
64 validate_request(req, state, |_: &T, _, _| {})
65 .await
66 .map(|(data, _)| Valid(data))
67 }
68}
69
70pub(crate) const LIVE_HEADER: &str = "x-renox-validate";
72
73#[derive(Clone)]
75pub(crate) struct SubmittedInput(pub Map<String, Value>);
76
77#[allow(clippy::result_large_err)] pub(crate) async fn validate_request<T>(
81 req: Request,
82 state: &AppState,
83 extra: impl FnOnce(&T, &Map<String, Value>, &mut Validator) + Send,
84) -> Result<(T, Map<String, Value>), Response>
85where
86 T: DeserializeOwned + Validate + Send,
87{
88 let live_field = req
89 .headers()
90 .get(LIVE_HEADER)
91 .and_then(|v| v.to_str().ok())
92 .filter(|f| !f.is_empty() && f.len() <= 200)
93 .map(str::to_owned);
94 let user = req
96 .extensions()
97 .get::<crate::auth::CurrentUser>()
98 .and_then(|current| current.user.clone());
99 let method = req.method().clone();
100 let path = req.uri().path().to_owned();
101 let locale_name = crate::i18n::request_locale(req.extensions(), state);
102 let locale = &Messages {
103 texts: state.translator.texts(&locale_name),
104 };
105 let content_type = req
106 .headers()
107 .get(CONTENT_TYPE)
108 .and_then(|v| v.to_str().ok())
109 .unwrap_or_default()
110 .to_ascii_lowercase();
111 let is_json = req
112 .headers()
113 .get(CONTENT_TYPE)
114 .and_then(|v| v.to_str().ok())
115 .is_some_and(|v| v.starts_with("application/json"));
116
117 let is_multipart = req
118 .headers()
119 .get(CONTENT_TYPE)
120 .and_then(|v| v.to_str().ok())
121 .is_some_and(|v| v.starts_with("multipart/form-data"));
122
123 let (parsed, input) = if matches!(*req.method(), Method::GET | Method::HEAD) {
124 let query = req.uri().query().unwrap_or_default().as_bytes();
125 let pairs = form_urlencoded::parse(query).into_owned().collect();
126 parse_pairs(pairs, &HashMap::new(), locale)
127 } else if is_multipart {
128 let multipart = Multipart::from_request(req, state)
129 .await
130 .map_err(IntoResponse::into_response)?;
131 let (pairs, uploads) = read_multipart(multipart)
133 .await
134 .map_err(IntoResponse::into_response)?;
135 parse_pairs(pairs, &uploads, locale)
136 } else {
137 let bytes = Bytes::from_request(req, state)
138 .await
139 .map_err(IntoResponse::into_response)?;
140 if is_json {
141 parse_json(&bytes, locale).map_err(IntoResponse::into_response)?
142 } else if !content_type.is_empty()
143 && !content_type.starts_with("application/x-www-form-urlencoded")
144 {
145 return Err(
146 (StatusCode::UNSUPPORTED_MEDIA_TYPE, "Send a form or JSON.").into_response()
147 );
148 } else {
149 let pairs = form_urlencoded::parse(&bytes).into_owned().collect();
150 parse_pairs(pairs, &HashMap::new(), locale)
151 }
152 };
153
154 let (mut data, mut errors): (T, Errors) = match parsed {
155 Parsed::Ok(data, errors) => (data, errors),
156 Parsed::Invalid(errors) => {
157 return Err(bagged::<T>(ValidationError::new(errors))
158 .with_input_map(input)
159 .into_response());
160 }
161 };
162 data.prepare();
163 let form = super::FormContext {
164 state,
165 user: user.as_deref(),
166 method: &method,
167 path: &path,
168 };
169 if !data
170 .authorize(&form)
171 .await
172 .map_err(IntoResponse::into_response)?
173 {
174 return Err(Error::Forbidden.into_response());
175 }
176 let mut validator = Validator::rules_with_texts(&data, locale.texts.clone());
177 extra(&data, &input, &mut validator);
178 let rule_errors = validator
179 .finish_for(state, user.as_deref())
180 .await
181 .map_err(IntoResponse::into_response)?;
182 for (field, messages) in rule_errors.iter() {
185 if !errors.has(field) {
186 for message in messages {
187 errors.add(field, message.clone());
188 }
189 }
190 }
191 if errors.is_empty() {
192 data.after(&form, &mut errors)
193 .await
194 .map_err(IntoResponse::into_response)?;
195 }
196 if let Some(field) = live_field {
200 let key = nested::normalize(&field);
202 let item = |name: &str| {
205 name.strip_prefix(key.as_str())
206 .and_then(|rest| rest.strip_prefix('.'))
207 .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()))
208 };
209 let messages: Vec<String> = errors
210 .iter()
211 .filter(|(name, _)| *name == key || item(name))
212 .flat_map(|(_, messages)| messages.iter().cloned())
213 .collect();
214 let body = serde_json::json!({ "field": field, "errors": messages });
215 return Err((axum::http::StatusCode::OK, axum::Json(body)).into_response());
216 }
217 if errors.is_empty() {
218 crate::context::set(SubmittedInput(input.clone()));
221 Ok((data, input))
222 } else {
223 Err(bagged::<T>(ValidationError::new(errors))
224 .with_input_map(input)
225 .into_response())
226 }
227}
228
229fn bagged<T: Validate>(error: ValidationError) -> ValidationError {
231 match T::ERROR_BAG {
232 Some(bag) => error.in_bag(bag),
233 None => error,
234 }
235}
236
237async fn read_multipart(
240 mut multipart: Multipart,
241) -> Result<(Vec<(String, String)>, HashMap<String, Upload>), MultipartError> {
242 let mut pairs = Vec::new();
243 let mut uploads = HashMap::new();
244 while let Some(field) = multipart.next_field().await? {
245 let Some(name) = field.name().map(str::to_owned) else {
246 continue;
247 };
248 match field.file_name().map(str::to_owned) {
249 Some(file_name) => {
250 let content_type = field.content_type().unwrap_or_default().to_owned();
251 let bytes = field.bytes().await?;
252 if file_name.is_empty() && bytes.is_empty() {
253 continue;
254 }
255 let token = upload::token(uploads.len());
256 uploads.insert(token.clone(), Upload::new(file_name, content_type, bytes));
257 pairs.push((name, token));
258 }
259 None => pairs.push((name, field.text().await?)),
260 }
261 }
262 Ok((pairs, uploads))
263}
264
265pub(crate) fn parse_pairs<T: DeserializeOwned>(
266 pairs: Vec<(String, String)>,
267 uploads: &HashMap<String, Upload>,
268 locale: &Messages,
269) -> (Parsed<T>, Map<String, Value>) {
270 if nested::is_nested(pairs.iter().map(|(k, _)| k.as_str())) {
271 return parse_nested(pairs, uploads, locale);
272 }
273 let mut input = Map::new();
274 for (key, value) in pairs.iter().filter(|(_, v)| !uploads.contains_key(v)) {
275 match input.get_mut(key) {
276 Some(Value::Array(values)) => values.push(Value::String(value.clone())),
277 Some(existing) => {
278 let first = existing.take();
279 *existing = Value::Array(vec![first, Value::String(value.clone())]);
280 }
281 None => {
282 input.insert(key.clone(), Value::String(value.clone()));
283 }
284 }
285 }
286
287 let mut filled: Vec<(String, String)> = pairs
295 .iter()
296 .filter(|(_, v)| !v.trim().is_empty())
297 .cloned()
298 .collect();
299 let mut errors = Errors::new();
300 let mut tries: HashMap<String, usize> = HashMap::new();
301 let mut coerced: std::collections::HashSet<String> = std::collections::HashSet::new();
302 let parsed = loop {
303 let encoded = form_urlencoded::Serializer::new(String::new())
304 .extend_pairs(&filled)
305 .finish();
306 let deserializer = serde_html_form::Deserializer::from_bytes(encoded.as_bytes());
309 match upload::with_uploads(uploads, || serde_path_to_error::deserialize(deserializer)) {
310 Ok(data) => break Parsed::Ok(data, errors),
311 Err(err) => {
312 let message = err.inner().to_string();
313 if let Some(field) = missing_field(&message)
314 && !filled.iter().any(|(k, _)| k == field)
315 {
316 filled.push((field.to_owned(), String::new()));
317 continue;
318 }
319 let path = err.path().to_string();
320 if coerce_browser_value(&mut filled, &path, &message, &mut coerced) {
321 continue;
322 }
323 let blank = filled.iter().any(|(k, v)| *k == path && v.is_empty());
324 let tried = tries.entry(path.clone()).or_default();
325 if *tried == 0 {
326 for (field, messages) in field_error(&path, &message, blank, locale).iter() {
327 for message in messages {
328 errors.add(field, message.clone());
329 }
330 }
331 }
332 let placeholder = match *tried {
335 0 => expected_variant(&message)
336 .or_else(|| PLACEHOLDERS.first().map(|p| (*p).to_owned())),
337 n => PLACEHOLDERS.get(n).map(|p| (*p).to_owned()),
338 };
339 *tried += 1;
340 match placeholder {
341 Some(value) if filled.iter().any(|(k, _)| *k == path) => {
342 filled.retain(|(k, _)| *k != path);
343 filled.push((path, value));
344 }
345 _ => break Parsed::Invalid(errors),
346 }
347 }
348 }
349 };
350 (parsed, input)
351}
352
353const PLACEHOLDERS: &[&str] = &["0", "false"];
354
355fn parse_nested<T: DeserializeOwned>(
361 pairs: Vec<(String, String)>,
362 uploads: &HashMap<String, Upload>,
363 locale: &Messages,
364) -> (Parsed<T>, Map<String, Value>) {
365 let shown: Vec<(String, String)> = pairs
366 .iter()
367 .filter(|(_, v)| !uploads.contains_key(v))
368 .cloned()
369 .collect();
370 let input = match nested::Node::build(&shown).into_json() {
371 Value::Object(map) => map,
372 _ => Map::new(),
373 };
374 let mut filled: Vec<(String, String)> = pairs
377 .into_iter()
378 .map(|(k, v)| {
379 let key = nested::normalize(&k);
380 if k.ends_with("[]") {
381 (key + "[]", v)
382 } else {
383 (key, v)
384 }
385 })
386 .collect();
387 let mut errors = Errors::new();
388 let mut tries: HashMap<String, usize> = HashMap::new();
389 let mut coerced: std::collections::HashSet<String> = std::collections::HashSet::new();
390 let parsed = loop {
391 let tree = nested::Node::build(&filled);
392 match upload::with_uploads(uploads, || nested::deserialize::<T>(tree)) {
393 Ok(data) => break Parsed::Ok(data, errors),
394 Err(err) => {
395 let message = err.inner().to_string();
396 let path = nested::normalize(&err.path().to_string());
397 let path = path.trim_start_matches('.').to_owned();
398 let path = if path.is_empty() {
399 ".".to_owned()
400 } else {
401 path
402 };
403 if let Some(field) = missing_field(&message) {
404 let full = if path == "." {
405 field.to_owned()
406 } else {
407 format!("{path}.{field}")
408 };
409 if !filled.iter().any(|(k, _)| *k == full) {
410 filled.push((full, String::new()));
411 continue;
412 }
413 }
414 if coerce_browser_value(&mut filled, &path, &message, &mut coerced) {
415 continue;
416 }
417 let blank = filled
418 .iter()
419 .any(|(k, v)| *k == path && v.trim().is_empty());
420 let tried = tries.entry(path.clone()).or_default();
421 if *tried == 0 {
422 for (field, messages) in field_error(&path, &message, blank, locale).iter() {
423 for message in messages {
424 errors.add(field, message.clone());
425 }
426 }
427 }
428 let placeholder = match *tried {
429 0 => expected_variant(&message)
430 .or_else(|| PLACEHOLDERS.first().map(|p| (*p).to_owned())),
431 n => PLACEHOLDERS.get(n).map(|p| (*p).to_owned()),
432 };
433 *tried += 1;
434 match placeholder {
435 Some(value) if filled.iter().any(|(k, _)| *k == path) => {
436 filled.retain(|(k, _)| *k != path);
437 filled.push((path, value));
438 }
439 _ => break Parsed::Invalid(errors),
440 }
441 }
442 }
443 };
444 (parsed, input)
445}
446
447fn expected_variant(message: &str) -> Option<String> {
450 let rest = message.split("expected one of").nth(1)?;
451 let rest = rest.trim_start_matches([':', ' ']);
452 let first = rest.split(',').next()?.trim().trim_matches('`');
453 (!first.is_empty()).then(|| first.to_owned())
454}
455
456fn is_bool_error(message: &str) -> bool {
460 message.contains("`true` or `false`") || message.contains("expected \"true\"")
461}
462
463fn coerce_browser_value(
468 filled: &mut [(String, String)],
469 path: &str,
470 message: &str,
471 coerced: &mut std::collections::HashSet<String>,
472) -> bool {
473 if coerced.contains(path) {
474 return false;
475 }
476 let mut changed = false;
477 for (_, value) in filled.iter_mut().filter(|(k, _)| k == path) {
478 let new = if is_bool_error(message) {
479 match value.trim().to_ascii_lowercase().as_str() {
480 "on" | "1" | "yes" | "checked" => Some("true".to_owned()),
481 "" | "off" | "0" | "no" => Some("false".to_owned()),
482 _ => None,
483 }
484 } else if is_minute_datetime(value) {
485 Some(format!("{value}:00"))
486 } else {
487 None
488 };
489 if let Some(new) = new {
490 *value = new;
491 changed = true;
492 }
493 }
494 if changed {
495 coerced.insert(path.to_owned());
496 }
497 changed
498}
499
500fn is_minute_datetime(value: &str) -> bool {
502 let b = value.as_bytes();
503 b.len() == 16
504 && b[4] == b'-'
505 && b[7] == b'-'
506 && b[10] == b'T'
507 && b[13] == b':'
508 && b.iter()
509 .enumerate()
510 .all(|(i, c)| matches!(i, 4 | 7 | 10 | 13) || c.is_ascii_digit())
511}
512
513fn parse_json<T: DeserializeOwned>(
514 bytes: &[u8],
515 locale: &Messages,
516) -> Result<(Parsed<T>, Map<String, Value>), Error> {
517 let input = match serde_json::from_slice(bytes) {
518 Ok(Value::Object(map)) => map,
519 Ok(_) => return Err(Error::BadRequest("The JSON body must be an object.".into())),
520 Err(err) => return Err(Error::BadRequest(format!("Invalid JSON: {err}"))),
521 };
522 let mut body = input.clone();
526 let mut errors = Errors::new();
527 let mut tries: HashMap<String, usize> = HashMap::new();
528 let parsed = loop {
529 match serde_path_to_error::deserialize(Value::Object(body.clone())) {
530 Ok(data) => break Parsed::Ok(data, errors),
531 Err(err) => {
532 let message = err.inner().to_string();
533 if let Some(field) = missing_field(&message)
534 && !body.contains_key(field)
535 {
536 body.insert(field.to_owned(), Value::String(String::new()));
537 continue;
538 }
539 let path = err.path().to_string();
540 let blank = body.get(&path).is_some_and(|v| {
541 v.is_null() || v.as_str().is_some_and(|s| s.trim().is_empty())
542 });
543 let tried = tries.entry(path.clone()).or_default();
544 if *tried == 0 {
545 for (field, messages) in field_error(&path, &message, blank, locale).iter() {
546 for message in messages {
547 errors.add(field, message.clone());
548 }
549 }
550 }
551 let placeholder = match (*tried, expected_variant(&message)) {
552 (0, Some(variant)) => Some(Value::String(variant)),
553 (n, _) => JSON_PLACEHOLDERS.get(n).map(|p| p()),
554 };
555 *tried += 1;
556 match placeholder {
557 Some(value) if body.contains_key(&path) => {
558 body.insert(path, value);
559 }
560 _ => break Parsed::Invalid(errors),
561 }
562 }
563 }
564 };
565 Ok((parsed, input))
566}
567
568const JSON_PLACEHOLDERS: &[fn() -> Value] = &[
570 || Value::from(0),
571 || Value::Bool(false),
572 || Value::String(String::new()),
573 || Value::Null,
574];
575
576fn missing_field(message: &str) -> Option<&str> {
577 message
578 .strip_prefix("missing field `")
579 .and_then(|rest| rest.split('`').next())
580}
581
582fn field_error(path: &str, message: &str, blank: bool, locale: &Messages) -> Errors {
585 let mut errors = Errors::new();
586 if let Some(field) = missing_field(message) {
587 let field = if path == "." {
588 field.to_owned()
589 } else {
590 format!("{path}.{field}")
591 };
592 errors.add(
593 &field,
594 render(&locale.template("required"), &locale.label(&field), &[]),
595 );
596 return errors;
597 }
598
599 let field = if path == "." { "_form" } else { path };
600 let numeric = [
601 "invalid digit",
602 "invalid float",
603 "cannot parse integer",
604 "number too large",
605 "expected i",
606 "expected u",
607 "expected f",
608 ]
609 .iter()
610 .any(|needle| message.contains(needle));
611 let key = match (blank, numeric) {
612 (true, _) => "required",
613 _ if message.contains(upload::NOT_A_FILE) => "file",
614 (false, true) => "numeric",
615 (false, false) => "invalid",
616 };
617 errors.add(
618 field,
619 render(&locale.template(key), &locale.label(field), &[]),
620 );
621 errors
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627 use serde::Deserialize;
628
629 fn plain() -> Messages {
630 Messages {
631 texts: Default::default(),
632 }
633 }
634
635 #[derive(Deserialize, Debug)]
636 #[allow(dead_code)]
637 struct Form {
638 name: String,
639 price: i64,
640 note: Option<String>,
641 }
642
643 fn parse(body: &str) -> Result<Form, Errors> {
644 let pairs = form_urlencoded::parse(body.as_bytes())
645 .into_owned()
646 .collect();
647 match parse_pairs::<Form>(pairs, &HashMap::new(), &plain()).0 {
648 Parsed::Ok(form, errors) if errors.is_empty() => Ok(form),
649 Parsed::Ok(_, errors) | Parsed::Invalid(errors) => Err(errors),
650 }
651 }
652
653 #[derive(Deserialize, Debug)]
654 struct Browser {
655 agree: bool,
656 news: bool,
657 starts_at: chrono::NaiveDateTime,
658 #[serde(default)]
659 tags: Vec<String>,
660 #[serde(default)]
661 sizes: Vec<i64>,
662 }
663
664 fn parse_browser(body: &str) -> Result<Browser, Errors> {
665 let pairs = form_urlencoded::parse(body.as_bytes())
666 .into_owned()
667 .collect();
668 match parse_pairs::<Browser>(pairs, &HashMap::new(), &plain()).0 {
669 Parsed::Ok(form, errors) if errors.is_empty() => Ok(form),
670 Parsed::Ok(_, errors) | Parsed::Invalid(errors) => Err(errors),
671 }
672 }
673
674 #[test]
675 fn bool_errors_are_recognised_in_either_wording() {
676 assert!(is_bool_error("provided string was not `true` or `false`"));
677 assert!(is_bool_error(
678 r#"invalid value: string "", expected "true", "on" or "false""#
679 ));
680 assert!(!is_bool_error("invalid digit found in string"));
681 let err = serde_html_form::from_str::<Browser>("agree=&news=&starts_at=x").unwrap_err();
683 assert!(is_bool_error(&err.to_string()), "{err}");
684 }
685
686 #[test]
687 fn reads_what_browsers_send() {
688 let form =
691 parse_browser("agree=on&starts_at=2026-10-01T10%3A30&tags=a&tags=b&sizes=1&sizes=2")
692 .unwrap();
693 assert!(form.agree);
694 assert!(!form.news);
695 assert_eq!(form.starts_at.to_string(), "2026-10-01 10:30:00");
696 assert_eq!(form.tags, ["a", "b"]);
697 assert_eq!(form.sizes, [1, 2]);
698 let form = parse_browser("agree=1&news=true&starts_at=2026-10-01T10%3A30%3A15").unwrap();
699 assert!(form.agree && form.news);
700 assert!(form.tags.is_empty());
701 let errors = parse_browser("agree=maybe&starts_at=soon").unwrap_err();
702 assert!(errors.has("agree") && errors.has("starts_at"));
703 for unchecked in ["agree=on&news=", "agree=on&news=off", "agree=on&news=0"] {
707 let form = parse_browser(&format!("{unchecked}&starts_at=2026-10-01T10%3A30"))
708 .unwrap_or_else(|errors| panic!("{unchecked}: {errors:?}"));
709 assert!(form.agree && !form.news, "{unchecked}");
710 }
711 }
712
713 #[test]
714 fn empty_fields_are_missing() {
715 let form = parse("name=Coffee&price=5¬e=").unwrap();
716 assert!(form.note.is_none());
717 assert_eq!(parse("name=&price=5").unwrap().name, "");
719 assert_eq!(parse("price=5").unwrap().name, "");
720 let errors = parse("name=Coffee&price=").unwrap_err();
721 assert_eq!(errors.first("price"), Some("The price field is required."));
722 }
723
724 #[test]
725 fn wrong_types_name_the_field() {
726 let errors = parse("name=Coffee&price=cheap").unwrap_err();
727 assert_eq!(errors.first("price"), Some("The price must be a number."));
728 }
729
730 #[test]
731 fn keeps_every_input_for_old_values() {
732 let pairs = form_urlencoded::parse(b"name=Coffee&tag=a&tag=b&price=")
733 .into_owned()
734 .collect();
735 let (_, input) = parse_pairs::<Form>(pairs, &HashMap::new(), &plain());
736 assert_eq!(input["name"], "Coffee");
737 assert_eq!(input["tag"], serde_json::json!(["a", "b"]));
738 assert_eq!(input["price"], "");
739 }
740
741 fn errors<T: DeserializeOwned>(parsed: Parsed<T>) -> Errors {
744 match parsed {
745 Parsed::Ok(_, errors) | Parsed::Invalid(errors) => errors,
746 }
747 }
748
749 #[test]
750 fn a_json_body_must_be_an_object() {
751 for body in [&b"[1, 2]"[..], b"\"name\"", b"42"] {
752 match parse_json::<Form>(body, &plain()) {
753 Err(Error::BadRequest(message)) => {
754 assert_eq!(message, "The JSON body must be an object.")
755 }
756 Err(other) => panic!("{other:?}"),
757 Ok(_) => panic!("accepted a body that isn't an object"),
758 }
759 }
760 assert!(matches!(
761 parse_json::<Form>(b"{", &plain()),
762 Err(Error::BadRequest(m)) if m.starts_with("Invalid JSON")
763 ));
764 }
765
766 #[derive(Deserialize, Debug)]
767 #[serde(deny_unknown_fields)]
768 #[allow(dead_code)]
769 struct Strict {
770 name: String,
771 }
772
773 #[test]
774 fn an_unknown_json_field_is_reported_under_its_name() {
775 let (parsed, _) = parse_json::<Strict>(br#"{"name": "a", "extra": 1}"#, &plain()).unwrap();
776 let errors = errors(parsed);
777 assert_eq!(errors.first("extra"), Some("The extra is invalid."));
778 }
779
780 #[test]
781 fn errors_at_the_root_name_the_field_or_the_form() {
782 let errors = field_error(".", "missing field `name`", false, &plain());
784 assert_eq!(errors.first("name"), Some("The name field is required."));
785 let errors = field_error(".", "invalid type: sequence", false, &plain());
787 assert!(errors.has("_form"), "{errors:?}");
788 let errors = field_error("address", "missing field `city`", false, &plain());
790 assert_eq!(
791 errors.first("address.city"),
792 Some("The city field is required.")
793 );
794 }
795
796 #[derive(Deserialize, Debug)]
797 #[allow(dead_code)]
798 struct Address {
799 city: String,
800 zip: i64,
801 }
802
803 #[derive(Deserialize, Debug)]
804 #[allow(dead_code)]
805 struct Shipping {
806 address: Address,
807 }
808
809 #[test]
810 fn json_errors_inside_an_object_name_the_nested_field() {
811 let (parsed, _) =
812 parse_json::<Shipping>(br#"{"address": {"city": "Bandung", "zip": "x"}}"#, &plain())
813 .unwrap();
814 let errors = errors(parsed);
815 assert!(errors.has("address.zip"), "{errors:?}");
816 let (parsed, _) = parse_json::<Shipping>(br#"{"address": {"zip": 1}}"#, &plain()).unwrap();
819 assert!(!errors_of_parsed_is_empty(parsed));
820 }
821
822 fn errors_of_parsed_is_empty<T>(parsed: Parsed<T>) -> bool {
823 match parsed {
824 Parsed::Ok(_, errors) => errors.is_empty(),
825 Parsed::Invalid(_) => false,
826 }
827 }
828
829 #[derive(Deserialize, Debug)]
830 #[allow(dead_code)]
831 struct Line {
832 name: String,
833 qty: i64,
834 }
835
836 #[derive(Deserialize, Debug)]
837 #[allow(dead_code)]
838 struct Order {
839 title: String,
840 lines: Vec<Line>,
841 }
842
843 fn nested_errors<T: DeserializeOwned>(body: &str) -> Errors {
844 let pairs = form_urlencoded::parse(body.as_bytes())
845 .into_owned()
846 .collect();
847 errors(parse_pairs::<T>(pairs, &HashMap::new(), &plain()).0)
848 }
849
850 #[test]
851 fn nested_forms_put_back_missing_fields_at_every_level() {
852 let errors = nested_errors::<Order>("lines%5B0%5D%5Bname%5D=Tea");
856 assert!(!errors.has("title"), "{errors:?}");
857 assert_eq!(
858 errors.first("lines.0.qty"),
859 Some("The qty field is required.")
860 );
861 let errors =
863 nested_errors::<Order>("title=T&lines%5B0%5D%5Bname%5D=Tea&lines%5B0%5D%5Bqty%5D=lots");
864 assert_eq!(
865 errors.first("lines.0.qty"),
866 Some("The qty must be a number.")
867 );
868 }
869
870 #[derive(Deserialize, Debug)]
871 #[serde(deny_unknown_fields)]
872 #[allow(dead_code)]
873 struct StrictOrder {
874 lines: Vec<Line>,
875 }
876
877 #[test]
878 fn an_unknown_nested_field_is_reported_under_its_name() {
879 let errors = nested_errors::<StrictOrder>(
880 "lines%5B0%5D%5Bname%5D=Tea&lines%5B0%5D%5Bqty%5D=1&extra%5Bx%5D=1",
881 );
882 assert_eq!(errors.first("extra"), Some("The extra is invalid."));
883 }
884
885 #[test]
886 fn a_nested_form_can_be_a_list_at_its_root() {
887 let pairs =
890 form_urlencoded::parse(b"0%5Bname%5D=Tea&0%5Bqty%5D=1&1%5Bname%5D=Coffee&1%5Bqty%5D=2")
891 .into_owned()
892 .collect();
893 let (parsed, input) = parse_pairs::<Vec<Line>>(pairs, &HashMap::new(), &plain());
894 match parsed {
895 Parsed::Ok(lines, errors) => {
896 assert!(errors.is_empty(), "{errors:?}");
897 assert_eq!(lines.len(), 2);
898 assert_eq!(lines[1].name, "Coffee");
899 }
900 Parsed::Invalid(errors) => panic!("{errors:?}"),
901 }
902 assert!(input.is_empty());
903 }
904
905 #[derive(Deserialize, Debug)]
906 #[allow(dead_code)]
907 struct Visit {
908 title: String,
909 slots: Vec<Slot>,
910 }
911
912 #[derive(Deserialize, Debug)]
913 #[allow(dead_code)]
914 struct Slot {
915 starts_at: chrono::NaiveDateTime,
916 }
917
918 #[test]
919 fn nested_forms_rewrite_what_browsers_send_and_try_every_placeholder() {
920 let pairs =
922 form_urlencoded::parse(b"title=T&slots%5B0%5D%5Bstarts_at%5D=2026-10-01T10%3A30")
923 .into_owned()
924 .collect();
925 let (parsed, _) = parse_pairs::<Visit>(pairs, &HashMap::new(), &plain());
926 assert!(errors_of_parsed_is_empty(parsed));
927 let errors =
931 nested_errors::<Visit>("title=T&slots%5B0%5D%5Bstarts_at%5D=2026-13-01T10%3A30");
932 let messages: Vec<_> = errors.iter().collect();
933 assert_eq!(messages.len(), 1, "{errors:?}");
934 assert_eq!(messages[0].0, "slots.0.starts_at");
935 assert_eq!(messages[0].1.len(), 1, "{errors:?}");
936 }
937
938 #[test]
939 fn a_plain_form_rewrites_a_value_once() {
940 let errors = parse_browser("agree=on&news=off&starts_at=2026-13-01T10%3A30").unwrap_err();
941 assert!(errors.has("starts_at"), "{errors:?}");
942 assert!(!errors.has("agree") && !errors.has("news"), "{errors:?}");
943 }
944
945 #[test]
946 fn a_json_enum_of_an_unknown_variant_gets_a_valid_one_as_placeholder() {
947 #[derive(Deserialize, Debug)]
948 #[serde(rename_all = "lowercase")]
949 #[allow(dead_code)]
950 enum Size {
951 Small,
952 Large,
953 }
954 #[derive(Deserialize, Debug)]
955 #[allow(dead_code)]
956 struct Cup {
957 size: Size,
958 name: String,
959 }
960 let (parsed, _) = parse_json::<Cup>(br#"{"size": "huge", "name": 5}"#, &plain()).unwrap();
961 let errors = errors(parsed);
962 assert!(errors.has("size"), "{errors:?}");
965 assert!(errors.has("name"), "{errors:?}");
966 }
967
968 #[tokio::test]
969 async fn a_multipart_part_without_a_name_is_skipped() {
970 let body = "--XX\r\nContent-Disposition: form-data\r\n\r\nlost\r\n\
971 --XX\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nCoffee\r\n\
972 --XX--\r\n";
973 let req = Request::builder()
974 .method(Method::POST)
975 .header(CONTENT_TYPE, "multipart/form-data; boundary=XX")
976 .body(axum::body::Body::from(body))
977 .unwrap();
978 let multipart = Multipart::from_request(req, &()).await.unwrap();
979 let (pairs, uploads) = read_multipart(multipart).await.unwrap();
980 assert_eq!(pairs, [("name".to_owned(), "Coffee".to_owned())]);
981 assert!(uploads.is_empty());
982 }
983}