Skip to main content

renox_core/validation/
extract.rs

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
18/// Deserializes and validates a form (urlencoded or multipart with `Upload`
19/// fields), a JSON body, or the query string for GET, with the type's
20/// `Validate` rules.
21///
22/// On failure, HTMX and JSON requests get `422` with the errors as JSON (the
23/// bundled script shows them next to the form's inputs); other requests are
24/// redirected back with the errors and old input flashed.
25///
26/// Empty form fields count as missing, like Laravel: use `Option<T>` for
27/// optional fields. A missing required field or a value of the wrong type
28/// becomes a validation error rather than a 400.
29pub struct Valid<T>(pub T);
30
31/// Built-in messages in the request's language, with the app's overrides.
32pub(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    /// Parsed, possibly with placeholders standing in for fields that
52    /// didn't parse; `Errors` holds those fields' errors.
53    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
70/// Sent by renox.js to validate one field as the user types (`data-live-validate`).
71pub(crate) const LIVE_HEADER: &str = "x-renox-validate";
72
73/// The input of the request's validated form, in [`crate::context`].
74#[derive(Clone)]
75pub(crate) struct SubmittedInput(pub Map<String, Value>);
76
77/// What `Valid` does, with `extra` rules added to `T`'s own; returns the
78/// data and the submitted fields (without files).
79#[allow(clippy::result_large_err)] // the rejection is a response, like axum's
80pub(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    // For `authorize` and `after`, taken before the body is read.
95    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        // MultipartError keeps axum's status, e.g. 413 over UPLOAD_MAX_SIZE.
132        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    // A field that didn't parse was checked with a placeholder; its own
183    // error is the one to show.
184    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    // Live validation (`data-live-validate` forms, renox.js): answer with
197    // one field's errors and stop, whatever the rest of the form says; the
198    // handler doesn't run, so nothing is saved.
199    if let Some(field) = live_field {
200        // `items[0][name]` from the page; its errors are keyed `items.0.name`.
201        let key = nested::normalize(&field);
202        // A list's own errors, or its items' (`tags.0`, `tags.1`) when
203        // the field is the list: `each(…)` rules report on the items.
204        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        // A later `ValidationError` (from a model's `saving` hook, say) is
219        // sent back with this input, so the form is refilled.
220        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
229/// The error in `T`'s named bag, if it has one.
230fn bagged<T: Validate>(error: ValidationError) -> ValidationError {
231    match T::ERROR_BAG {
232        Some(bag) => error.in_bag(bag),
233        None => error,
234    }
235}
236
237/// Text fields as pairs, and files replaced by tokens that `Upload`'s
238/// `Deserialize` resolves. An empty file input counts as missing.
239async 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    // Empty inputs are dropped so `Option<T>` fields become `None`. A field
288    // serde then reports missing is put back as "", so text fields still reach
289    // the rules (and their labels); a number left blank becomes "required".
290    //
291    // A field that doesn't parse (`price=abc` for an i64) gets its error, then
292    // a placeholder so the rest of the form still parses and every other
293    // field's rules run too: all errors show at once.
294    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        // serde_html_form, unlike serde_urlencoded, reads repeated names (a
307        // multi-select, a group of checkboxes) into a `Vec`.
308        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                // Stand-ins that parse as most field types: an enum's first
333                // variant (named in the error), numbers and text, booleans.
334                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
355/// A form with nested names (`items[0][name]`): read as a tree, so lists of
356/// structs and maps work. Empty values are kept (an `Option` reads "" as
357/// `None`), so the rows of a list keep their numbers, and with them their
358/// errors (`items.2.name`). Missing fields and fields that don't parse are
359/// handled as for a plain form.
360fn 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    // Dotted names, keeping a `[]` ending: `name[]` is a list even when
375    // sent once, so it never fills a text field.
376    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
447/// The first valid value an enum's error names: serde's "unknown variant
448/// `x`, expected one of `a`, `b`" or DbEnum's "expected one of: a, b".
449fn 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
456/// Whether `message` is serde_html_form's "this isn't a bool": 0.2 says
457/// "provided string was not `true` or `false`" (as does `nested.rs`), 0.4
458/// says `invalid value: string "", expected "true", "on" or "false"` (#160).
459fn is_bool_error(message: &str) -> bool {
460    message.contains("`true` or `false`") || message.contains("expected \"true\"")
461}
462
463/// Rewrites what browsers send into what Rust types parse, once per field:
464/// a checkbox's `on` (or `1`, `yes`) is `true`, an unchecked one (missing)
465/// is `false`; `<input type="datetime-local">` leaves out the seconds.
466/// Returns whether `path` was rewritten, so deserializing can try again.
467fn 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
500/// `2026-10-01T10:30`, as `<input type="datetime-local">` sends it.
501fn 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    // As with forms: a missing field is put back as "" so its rules (and
523    // `required`) run, and a field of the wrong type gets its error and a
524    // placeholder, so every field's errors show at once.
525    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
568/// JSON stand-ins for a field of the wrong type, tried in turn.
569const 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
582/// Turns a deserialization error into a message for the field it concerns.
583/// `blank` means the field was submitted empty.
584fn 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        // What serde_html_form says today, whichever version Cargo picked.
682        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        // A checked checkbox sends "on", an unchecked one nothing;
689        // datetime-local has no seconds; multi-selects repeat the name.
690        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        // A box sent empty or "off" (a hidden input before the checkbox, or a
704        // script) is unchecked. serde_html_form 0.4 words the bool error
705        // differently from 0.2, and the rewrite must catch both (#160).
706        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&note=").unwrap();
716        assert!(form.note.is_none());
717        // Text left empty reaches the rules as "".
718        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    // #250: JSON bodies and nested forms beyond the happy path.
742
743    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        // A missing field reported at the root is that field's error.
783        let errors = field_error(".", "missing field `name`", false, &plain());
784        assert_eq!(errors.first("name"), Some("The name field is required."));
785        // Anything else at the root belongs to the whole form.
786        let errors = field_error(".", "invalid type: sequence", false, &plain());
787        assert!(errors.has("_form"), "{errors:?}");
788        // A missing field inside an object keeps its path.
789        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        // A field missing inside an object can't be put back at the top:
817        // it's reported, not retried forever.
818        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        // `title` (top level) and `lines[0][qty]` are missing: both are put
853        // back empty, so text reaches the rules as "" and a number is
854        // "required".
855        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        // A wrong type deep down is named by its full path.
862        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        // Every name starts with a number: the form is a list, and there is
888        // no object to keep as old input.
889        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        // `datetime-local` leaves out the seconds: put back, deep down too.
921        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        // A month 13 still fails once rewritten; it isn't rewritten twice,
928        // and no placeholder ("0", "false") reads as a date either, so the
929        // form is invalid with one error for the field.
930        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        // Both fields are reported: the placeholder variant let the rest
963        // of the body be read.
964        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}