umbral_core/forms.rs
1//! Form parsing, validation, and HTML rendering.
2//!
3//! ## Two names, two layers (gaps2 #19)
4//!
5//! - **`FormValidate` trait** (the primitive, was `Form`): a struct
6//! implements this to provide a `validate(&HashMap)` method. The
7//! `#[derive(Form)]` macro emits it.
8//! - **`Form<T>` extractor** (the axum entry point): wraps the
9//! parsed-and-validated `T` in a `Result<T, FormErrors>`. Use in
10//! handler signatures: `Form<ContactForm>`.
11//!
12//! The trait used to be called `Form` too, but that collided with
13//! the extractor type in the same module. The name with generics
14//! went to the extractor (matches `axum::extract::Form<T>` /
15//! `axum::Json<T>` shape) and the trait got the more descriptive
16//! `FormValidate`.
17//!
18//! The piece that fills out the request-handling story between axum's
19//! `Form<T>` extractor (raw key/value access) and a typed Rust struct
20//! (the application's view of validated input). umbral's first cut is
21//! the primitive layer richer form abstractions sit on top of.
22//!
23//! ## v1 shape
24//!
25//! - [`Field`] types per HTML input shape (`TextField`,
26//! `IntegerField`, `EmailField`, `PasswordField`, `BooleanField`,
27//! `DateField`, `TimeField`).
28//! - Field-level validators ([`Required`], [`MinLength`],
29//! [`MaxLength`], [`Pattern`]) plus the convenience built-in checks
30//! each field type does for its own shape (e.g. `EmailField` runs
31//! `Pattern` against an email regex by default).
32//! - [`ValidationErrors`] is a map of field-name -> error messages.
33//! Forms accumulate every per-field error before returning, so the
34//! user sees the whole form's problems at once.
35//! - HTML rendering: every field type has [`Field::render_html`]
36//! that emits a single `<input>` (or `<textarea>`) with the right
37//! `type`, `name`, `value`, and a `required` attribute when the
38//! field is required.
39//!
40//! ## v1 caps
41//!
42//! - No `#[derive(Form)]` macro. Users compose forms by hand:
43//! `LoginForm::validate(&form_data)` is a function that reads each
44//! field, accumulates errors, returns either the typed struct or
45//! `Err(ValidationErrors)`. The derive lands as a future round.
46//! - No file uploads (multipart); HTML-only.
47//! - No localized error messages.
48
49use std::collections::HashMap;
50
51use async_trait::async_trait;
52
53/// Re-exported so the `#[derive(Form)]` macro can name
54/// `::umbral::forms::async_trait` on the impl it emits without the
55/// consumer crate having to depend on `async-trait` directly.
56#[doc(hidden)]
57pub use async_trait::async_trait as async_trait_reexport;
58
59// =========================================================================
60// Form trait. The `#[derive(Form)]` macro emits an impl of this. User
61// code can also impl it by hand for the rare "I want different
62// semantics than the macro" case.
63// =========================================================================
64
65/// The contract a typed form satisfies. `validate` reads form data
66/// (a `HashMap<String, String>`, the natural shape after
67/// `serde_urlencoded` or axum's `Form` extractor) and produces either
68/// the typed struct or a `ValidationErrors` map describing every
69/// problem at once.
70///
71/// `render_html` writes the form's HTML inputs, prefilled from a
72/// HashMap on the re-render path (after a validation failure or on
73/// edit views). The default impl walks `fields()` and concatenates
74/// each field's `render_html` — most macro-derived forms inherit
75/// this and only override when they need custom layout.
76#[async_trait]
77pub trait FormValidate: Sized {
78 /// Parse and validate the form's input. Async because FK / M2M
79 /// fields verify existence through the ORM before insert. Returns
80 /// the typed struct on success; returns `ValidationErrors` with
81 /// every field's problems accumulated on failure.
82 async fn validate(data: &HashMap<String, String>) -> Result<Self, ValidationErrors>;
83
84 /// The field declarations this form carries. Sync — kinds /
85 /// validators only, no live options. Used by the default
86 /// `render_html` to walk them in declaration order. The macro
87 /// emits one entry per struct field.
88 fn fields() -> Vec<Field>;
89
90 /// Render every field as an HTML `<label>` + `<input>` pair,
91 /// prefilled from `data`. Wraps each in a `<div class="field">`
92 /// for styling. Async because `ModelChoice` / `ModelMultiChoice`
93 /// fetch their `<select>` options from the DB. Override if you
94 /// want a non-default layout.
95 async fn render_html(data: &HashMap<String, String>) -> String {
96 let mut out = String::new();
97 for field in Self::fields() {
98 let value = data.get(&field.name).map(String::as_str).unwrap_or("");
99 out.push_str("<div class=\"field\">");
100 out.push_str(&format!(
101 "<label for=\"{name}\">{name}</label>",
102 name = html_escape(&field.name)
103 ));
104 out.push_str(&field.render_html_async(value).await);
105 out.push_str("</div>");
106 }
107 out
108 }
109}
110
111// =========================================================================
112// Errors. One per-field message list, plus a "non-field" bucket for
113// cross-field issues (passwords don't match, etc.).
114// =========================================================================
115
116/// A collection of per-field validation errors. Forms accumulate
117/// these and return the whole map at once.
118#[derive(Debug, Default, Clone, PartialEq, Eq)]
119pub struct ValidationErrors {
120 /// Per-field error messages. Each field's vec may carry multiple
121 /// messages (e.g. both Required and Pattern fire).
122 pub fields: HashMap<String, Vec<String>>,
123 /// Cross-field errors that don't belong to one field. Use for
124 /// "password and confirm don't match", etc.
125 pub non_field: Vec<String>,
126}
127
128impl ValidationErrors {
129 /// Construct an empty error set.
130 pub fn new() -> Self {
131 Self::default()
132 }
133
134 /// Add an error to one field. Multiple calls accumulate.
135 pub fn add(&mut self, field: &str, message: impl Into<String>) {
136 self.fields
137 .entry(field.to_string())
138 .or_default()
139 .push(message.into());
140 }
141
142 /// Add a cross-field error.
143 pub fn add_non_field(&mut self, message: impl Into<String>) {
144 self.non_field.push(message.into());
145 }
146
147 /// Has any error been recorded?
148 pub fn is_empty(&self) -> bool {
149 self.fields.is_empty() && self.non_field.is_empty()
150 }
151
152 /// Convert to `Result<(), ValidationErrors>`, returning `Ok(())`
153 /// when no errors have accumulated. Use as the last step in a
154 /// form's `validate` method.
155 pub fn into_result(self) -> Result<(), Self> {
156 if self.is_empty() { Ok(()) } else { Err(self) }
157 }
158}
159
160impl std::fmt::Display for ValidationErrors {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 for msg in &self.non_field {
163 writeln!(f, "form: {msg}")?;
164 }
165 for (field, msgs) in &self.fields {
166 for msg in msgs {
167 writeln!(f, "{field}: {msg}")?;
168 }
169 }
170 Ok(())
171 }
172}
173
174impl std::error::Error for ValidationErrors {}
175
176// =========================================================================
177// Validators. Reusable functions that take a value and either succeed
178// or push an error onto the per-field list. Each is a small struct
179// implementing `Validator` so users can build a Vec<Box<dyn ...>>.
180// =========================================================================
181
182/// One validator's verdict.
183pub trait Validator: Send + Sync {
184 /// Check the value. `field_name` is included for the error
185 /// message. Return `Ok(())` to accept, `Err(message)` to reject.
186 fn check(&self, field_name: &str, value: &str) -> Result<(), String>;
187}
188
189/// The field must not be empty.
190pub struct Required;
191impl Validator for Required {
192 fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
193 if value.trim().is_empty() {
194 Err(format!("{field_name} is required"))
195 } else {
196 Ok(())
197 }
198 }
199}
200
201/// The field's length (in characters) must be at least `n`.
202pub struct MinLength(pub usize);
203impl Validator for MinLength {
204 fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
205 if value.chars().count() < self.0 {
206 Err(format!(
207 "{field_name} must be at least {} characters",
208 self.0
209 ))
210 } else {
211 Ok(())
212 }
213 }
214}
215
216/// The field's length (in characters) must be at most `n`.
217pub struct MaxLength(pub usize);
218impl Validator for MaxLength {
219 fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
220 if value.chars().count() > self.0 {
221 Err(format!(
222 "{field_name} must be at most {} characters",
223 self.0
224 ))
225 } else {
226 Ok(())
227 }
228 }
229}
230
231/// A simple "must look like an email" check. Not RFC 5322 strict —
232/// covers the 99% case (one `@`, non-empty local part, dot in the
233/// domain). Users with stricter needs swap in a real regex.
234pub struct EmailFormat;
235impl Validator for EmailFormat {
236 fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
237 let Some((local, domain)) = value.split_once('@') else {
238 return Err(format!("{field_name} must contain `@`"));
239 };
240 if local.is_empty() {
241 return Err(format!("{field_name} is missing a local part before `@`"));
242 }
243 if !domain.contains('.') {
244 return Err(format!(
245 "{field_name}'s domain must contain at least one `.`"
246 ));
247 }
248 if domain.starts_with('.') || domain.ends_with('.') {
249 return Err(format!("{field_name}'s domain is malformed"));
250 }
251 Ok(())
252 }
253}
254
255/// Regex-pattern validator — the catch-all shape for "value must
256/// match this format". Reject the field with a user-supplied message
257/// when the pattern doesn't match.
258///
259/// Used by `#[form(regex = "...")]` on derived form structs AND by
260/// the `Field::regex` / `Field::phone` / `Field::url` convenience
261/// constructors. The pattern is parsed once at construction time
262/// (panics if invalid — a hardcoded pattern can't go wrong in
263/// production; user-supplied patterns are validated at `cargo build`
264/// time through the macro's `Regex::new(...)` compile-time call).
265pub struct RegexFormat {
266 pattern: regex::Regex,
267 message: String,
268}
269
270impl RegexFormat {
271 /// Build a regex validator from a pattern + a human message. The
272 /// pattern is compiled eagerly — use `regex::Regex::new` shape
273 /// (no leading slash, no flags suffix). Panics on an invalid
274 /// pattern; the derive macro catches this at build time by
275 /// emitting the literal into the generated code.
276 pub fn new(pattern: &str, message: impl Into<String>) -> Self {
277 Self {
278 pattern: regex::Regex::new(pattern)
279 .unwrap_or_else(|e| panic!("RegexFormat: invalid pattern `{pattern}`: {e}")),
280 message: message.into(),
281 }
282 }
283}
284
285impl Validator for RegexFormat {
286 fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
287 if self.pattern.is_match(value) {
288 Ok(())
289 } else {
290 // `{field}` placeholder in the message gets substituted
291 // with the actual field name — lets one message template
292 // be reused across forms ("{field} must start with `+`").
293 // Most callers won't use the placeholder; substitution is
294 // a no-op when it's absent.
295 Err(self.message.replace("{field}", field_name))
296 }
297 }
298}
299
300/// E.164 international phone-number format — the standard the
301/// telecoms industry uses. `+<country code><subscriber number>`
302/// where the country code is 1-3 digits and the subscriber number
303/// is up to 14 digits, no spaces or punctuation.
304///
305/// Catches the most common typo'd-phone cases ("07065" with no
306/// country code, "+0..." starting with zero, letters mixed in,
307/// dashes / spaces / parens that proper E.164 doesn't allow).
308/// Users who need a softer "accept anything that looks vaguely
309/// phone-ish" check can write their own regex via
310/// `#[form(regex = "...", message = "...")]`.
311pub const PHONE_E164_PATTERN: &str = r"^\+[1-9]\d{1,14}$";
312
313/// URL validator — http(s) only, requires a host, accepts an
314/// optional path/query/fragment. Conservative on purpose:
315/// `ftp://`, `mailto:`, etc. get rejected so a form that promises
316/// "URL" doesn't end up persisting a non-web scheme.
317pub const URL_PATTERN: &str = r"^https?://[A-Za-z0-9._~:%/?#\[\]@!$&'()*+,;=-]+$";
318
319// =========================================================================
320// Field types. Each owns its name, value (after parsing), and a list
321// of validators that fire in order. `render_html` emits the matching
322// HTML input.
323// =========================================================================
324
325/// How to parse a submitted FK id string. Resolved from the target
326/// model's PK SqlType at render/validate time.
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub enum PkKind {
329 BigInt,
330 Uuid,
331 Text,
332}
333
334/// What HTML `<input type>` a field renders as. The form module
335/// uses this for `render_html`; it's the same set the admin's
336/// `input_kind` produces.
337#[derive(Debug, Clone, Copy)]
338pub enum InputKind {
339 Text,
340 Number,
341 Email,
342 /// gaps2 #19 follow-up — `<input type="tel">` so mobile
343 /// browsers pop the number keypad. Phone fields don't get
344 /// browser-side validation (there's no canonical phone format
345 /// the browser knows about), so the server-side regex is what
346 /// catches typo'd input.
347 Tel,
348 /// `<input type="url">`. Browser does shallow validation
349 /// (requires a scheme + host) but the server-side regex is
350 /// stricter about which schemes are allowed.
351 Url,
352 Password,
353 Checkbox,
354 Date,
355 Time,
356 DatetimeLocal,
357 Textarea,
358 /// `<input type="file">`. Submission is opaque: the admin's
359 /// multipart handler stores the upload and puts the resulting
360 /// storage *key* (a plain string) into the form data, which the
361 /// `FileField` / `ImageField` newtype is constructed from. The
362 /// rendered input never echoes the key as its `value` (browsers
363 /// reject programmatic file-input values), so it has no prefill.
364 File,
365 /// Closed-set enum (`#[umbral(choices)]`). Options are compile-time
366 /// `(value, label)` pairs from `ChoiceField`. Rendered as a
367 /// `<select>`, not an `<input>`.
368 Select,
369 /// FK / forward O2O to another model. Options are fetched at render
370 /// time (async). `label_field` overrides the default label column.
371 ModelChoice {
372 target_table: &'static str,
373 label_field: Option<&'static str>,
374 pk_kind: PkKind,
375 },
376 /// M2M relation. Submits a list of child ids; written as junction
377 /// rows after the parent insert.
378 ModelMultiChoice {
379 target_table: &'static str,
380 label_field: Option<&'static str>,
381 pk_kind: PkKind,
382 },
383}
384
385impl InputKind {
386 fn html_type(self) -> &'static str {
387 match self {
388 InputKind::Text | InputKind::Textarea => "text",
389 InputKind::Number => "number",
390 InputKind::Email => "email",
391 InputKind::Tel => "tel",
392 InputKind::Url => "url",
393 InputKind::Password => "password",
394 InputKind::Checkbox => "checkbox",
395 InputKind::Date => "date",
396 InputKind::Time => "time",
397 InputKind::DatetimeLocal => "datetime-local",
398 InputKind::File => "file",
399 // `Select` / `ModelChoice` / `ModelMultiChoice` have no
400 // `<input type>`; their render arms build a `<select>` and
401 // never call `html_type`. "text" is a harmless default to
402 // keep the match exhaustive.
403 InputKind::Select
404 | InputKind::ModelChoice { .. }
405 | InputKind::ModelMultiChoice { .. } => "text",
406 }
407 }
408}
409
410/// A single form field: name + kind + validators. The field doesn't
411/// own its parsed value; `validate` reads from the form-data map and
412/// pushes errors onto the accumulator.
413pub struct Field {
414 pub name: String,
415 pub kind: InputKind,
416 pub required: bool,
417 pub validators: Vec<Box<dyn Validator>>,
418 /// `(value, label)` pairs for `Select` fields. Empty for every
419 /// other kind. For `ModelChoice` / `ModelMultiChoice` the options
420 /// are fetched async at render time (Task 6), so they stay empty
421 /// here too.
422 pub options: Vec<(String, String)>,
423}
424
425impl Field {
426 /// New text field. Caller adds validators via builder methods.
427 pub fn text(name: impl Into<String>) -> Self {
428 Self {
429 name: name.into(),
430 kind: InputKind::Text,
431 required: true,
432 validators: vec![Box::new(Required)],
433 options: Vec::new(),
434 }
435 }
436
437 /// New email field. Carries `EmailFormat` by default.
438 pub fn email(name: impl Into<String>) -> Self {
439 let mut f = Self::text(name);
440 f.kind = InputKind::Email;
441 f.validators.push(Box::new(EmailFormat));
442 f
443 }
444
445 /// Attach a regex-pattern validator to an existing field.
446 /// Composes with `Required`, `MinLength`, `MaxLength`, etc. —
447 /// the regex check fires after the others, so empty / missing
448 /// values surface the right "is required" error rather than
449 /// a confusing "doesn't match pattern" error.
450 ///
451 /// The pattern is compiled eagerly — an invalid regex panics at
452 /// construction time. The derive macro short-circuits this by
453 /// emitting the literal pattern, so a malformed
454 /// `#[form(regex = "...")]` surfaces as a panic in tests rather
455 /// than silently passing every input.
456 ///
457 /// Use `{field}` in the message to interpolate the field name.
458 ///
459 /// ```ignore
460 /// let f = Field::text("invoice_id")
461 /// .regex(r"^INV-\d{6}$", "{field} must look like `INV-123456`");
462 /// ```
463 pub fn regex(mut self, pattern: &str, message: impl Into<String>) -> Self {
464 self.validators
465 .push(Box::new(RegexFormat::new(pattern, message)));
466 self
467 }
468
469 /// New phone field — E.164 international format
470 /// (`+<country><subscriber>`, e.g. `+14155551234`). Catches the
471 /// common typo'd-phone cases ("07065", "+0…", letters mixed in,
472 /// dashes / spaces / parens that proper E.164 doesn't allow).
473 /// Renders as `<input type="tel">` so mobile browsers pop the
474 /// number keypad.
475 ///
476 /// Soft-validation case ("accept anything phone-ish, even
477 /// without country code"): use `Field::text` + your own
478 /// `.regex(...)`. The strict E.164 pattern here is the right
479 /// default because every form that asks for a phone number
480 /// SHOULD be storing them in E.164 (the only shape that
481 /// round-trips across providers / SMS gateways / address books).
482 pub fn phone(name: impl Into<String>) -> Self {
483 let mut f = Self::text(name);
484 f.kind = InputKind::Tel;
485 f.validators.push(Box::new(RegexFormat::new(
486 PHONE_E164_PATTERN,
487 "{field} must be E.164 format — `+` then country code then number, no spaces",
488 )));
489 f
490 }
491
492 /// New URL field — http(s) only, requires a host. Conservative:
493 /// `ftp://` / `mailto:` / etc. get rejected so a form that
494 /// promises "URL" doesn't persist a non-web scheme.
495 pub fn url(name: impl Into<String>) -> Self {
496 let mut f = Self::text(name);
497 f.kind = InputKind::Url;
498 f.validators.push(Box::new(RegexFormat::new(
499 URL_PATTERN,
500 "{field} must be an http(s):// URL",
501 )));
502 f
503 }
504
505 /// New password field. Identical validation rules to text; the
506 /// difference is the rendered `<input type="password">` so the
507 /// browser masks input.
508 pub fn password(name: impl Into<String>) -> Self {
509 let mut f = Self::text(name);
510 f.kind = InputKind::Password;
511 f
512 }
513
514 /// New file field — renders `<input type="file">`. One kind covers
515 /// both `FileField` and `ImageField`: the Form-side input is just a
516 /// file input (the admin's image-preview is a column-`widget`
517 /// concern, not a form-input concern).
518 ///
519 /// The submitted value is the opaque storage *key* the admin's
520 /// multipart handler stored after the upload; there's nothing to
521 /// validate about a key string beyond required/optional, so the
522 /// only validator is `Required` (dropped via `.optional()` for a
523 /// nullable field). Length / regex / format validators don't apply.
524 pub fn file(name: impl Into<String>) -> Self {
525 Self {
526 name: name.into(),
527 kind: InputKind::File,
528 required: true,
529 validators: vec![Box::new(Required)],
530 options: Vec::new(),
531 }
532 }
533
534 /// New integer field. Validates that the value parses as `i64`.
535 pub fn integer(name: impl Into<String>) -> Self {
536 Self {
537 name: name.into(),
538 kind: InputKind::Number,
539 required: true,
540 validators: vec![Box::new(Required), Box::new(IntegerFormat)],
541 options: Vec::new(),
542 }
543 }
544
545 /// New floating-point field. Renders as `<input type="number">`
546 /// (no `step` set; HTML's default accepts decimals). Validates
547 /// only `Required`; the macro's parse step is what catches
548 /// non-numeric input — the field-level validator would reject
549 /// integer literals which is the wrong shape for an f64 field.
550 pub fn float(name: impl Into<String>) -> Self {
551 Self {
552 name: name.into(),
553 kind: InputKind::Number,
554 required: true,
555 validators: vec![Box::new(Required), Box::new(FloatFormat)],
556 options: Vec::new(),
557 }
558 }
559
560 /// New boolean field. Required-by-default would be wrong here
561 /// (HTML emits the field key only when the box is checked), so
562 /// boolean fields skip `Required`.
563 pub fn boolean(name: impl Into<String>) -> Self {
564 Self {
565 name: name.into(),
566 kind: InputKind::Checkbox,
567 required: false,
568 validators: Vec::new(),
569 options: Vec::new(),
570 }
571 }
572
573 /// New closed-set select field. `options` are `(value, label)`
574 /// pairs from a `ChoiceField`'s `VALUES`/`LABELS`. `nullable`
575 /// prepends a leading empty option and drops `Required`.
576 pub fn select(name: impl Into<String>, options: Vec<(String, String)>, nullable: bool) -> Self {
577 let mut opts = options;
578 if nullable {
579 opts.insert(0, (String::new(), String::new()));
580 }
581 Self {
582 name: name.into(),
583 kind: InputKind::Select,
584 required: !nullable,
585 validators: Vec::new(),
586 options: opts,
587 }
588 }
589
590 /// New single-select FK field. `options` are fetched async at
591 /// render time (Task 6); at validate time only `pk_kind` is used to
592 /// parse the submitted id.
593 pub fn model_choice(
594 name: impl Into<String>,
595 target_table: &'static str,
596 label_field: Option<&'static str>,
597 pk_kind: PkKind,
598 nullable: bool,
599 ) -> Self {
600 Self {
601 name: name.into(),
602 kind: InputKind::ModelChoice {
603 target_table,
604 label_field,
605 pk_kind,
606 },
607 required: !nullable,
608 validators: Vec::new(),
609 options: Vec::new(),
610 }
611 }
612
613 /// New multi-select M2M field. Options are fetched async at render
614 /// time; submission is a list of child ids written to the junction
615 /// table after the parent insert.
616 pub fn model_multi_choice(
617 name: impl Into<String>,
618 target_table: &'static str,
619 label_field: Option<&'static str>,
620 pk_kind: PkKind,
621 ) -> Self {
622 Self {
623 name: name.into(),
624 kind: InputKind::ModelMultiChoice {
625 target_table,
626 label_field,
627 pk_kind,
628 },
629 required: false,
630 validators: Vec::new(),
631 options: Vec::new(),
632 }
633 }
634
635 /// Mark the field as optional. The `validate` method
636 /// short-circuits when `required` is false and the value is
637 /// empty, so the `Required` validator (if any) doesn't fire on
638 /// an empty optional field. Validators that wrap non-empty
639 /// values (`MinLength`, `Pattern`, ...) still run when there's
640 /// something to check.
641 pub fn optional(mut self) -> Self {
642 self.required = false;
643 self
644 }
645
646 /// Add a `MinLength(n)` validator. Builder method, returns self.
647 pub fn min_length(mut self, n: usize) -> Self {
648 self.validators.push(Box::new(MinLength(n)));
649 self
650 }
651
652 /// Add a `MaxLength(n)` validator. Builder method, returns self.
653 pub fn max_length(mut self, n: usize) -> Self {
654 self.validators.push(Box::new(MaxLength(n)));
655 self
656 }
657
658 /// Add a custom validator. Named `with_validator` rather than
659 /// `add` so it doesn't shadow `std::ops::Add::add` for clippy.
660 pub fn with_validator(mut self, v: impl Validator + 'static) -> Self {
661 self.validators.push(Box::new(v));
662 self
663 }
664
665 /// Run every validator over `value`. Errors push onto `errors`.
666 /// An empty value on a non-required field skips validation
667 /// entirely (an optional empty input is valid).
668 pub fn validate(&self, value: &str, errors: &mut ValidationErrors) {
669 if !self.required && value.is_empty() {
670 return;
671 }
672 for v in &self.validators {
673 if let Err(msg) = v.check(&self.name, value) {
674 errors.add(&self.name, msg);
675 }
676 }
677 }
678
679 /// Render the field as a single HTML `<input>` element. The
680 /// `value` is the form's prefill (empty for a fresh form, the
681 /// raw user input on a re-render after validation failed).
682 pub fn render_html(&self, value: &str) -> String {
683 let safe_value = html_escape(value);
684 let name = html_escape(&self.name);
685 let required = if self.required { " required" } else { "" };
686 match self.kind {
687 InputKind::Textarea => {
688 format!("<textarea name=\"{name}\"{required}>{safe_value}</textarea>",)
689 }
690 InputKind::Checkbox => {
691 let checked = if value == "true" || value == "on" || value == "1" {
692 " checked"
693 } else {
694 ""
695 };
696 format!("<input type=\"checkbox\" name=\"{name}\" value=\"true\"{checked}>",)
697 }
698 InputKind::Select => {
699 let mut s = format!("<select name=\"{name}\"{required}>", required = required);
700 for (val, label) in &self.options {
701 let selected = if val == value { " selected" } else { "" };
702 s.push_str(&format!(
703 "<option value=\"{v}\"{selected}>{l}</option>",
704 v = html_escape(val),
705 l = html_escape(label),
706 ));
707 }
708 s.push_str("</select>");
709 s
710 }
711 InputKind::File => format!(
712 // No `value` attribute: browsers reject programmatic
713 // file-input values, and echoing the storage key into
714 // the markup would leak it. The prefill is intentionally
715 // dropped.
716 "<input type=\"file\" name=\"{name}\"{required}>",
717 ),
718 other => format!(
719 "<input type=\"{ty}\" name=\"{name}\" value=\"{safe_value}\"{required}>",
720 ty = other.html_type(),
721 ),
722 }
723 }
724
725 /// Async render entry point. `ModelChoice` / `ModelMultiChoice`
726 /// fetch their options first, then render the `<select>`; every
727 /// other kind defers to the sync `render_html`.
728 pub async fn render_html_async(&self, value: &str) -> String {
729 match self.kind {
730 InputKind::ModelChoice {
731 target_table,
732 label_field,
733 ..
734 } => {
735 let options =
736 crate::orm::forms_runtime::fetch_model_options(target_table, label_field).await;
737 self.render_select(&options, value, false)
738 }
739 InputKind::ModelMultiChoice {
740 target_table,
741 label_field,
742 ..
743 } => {
744 let options =
745 crate::orm::forms_runtime::fetch_model_options(target_table, label_field).await;
746 self.render_select(&options, value, true)
747 }
748 _ => self.render_html(value),
749 }
750 }
751
752 /// Shared `<select>` writer for `ModelChoice` / `ModelMultiChoice`.
753 /// `multiple` adds the `multiple` attribute. `selected` matches the
754 /// option value against the prefill `value`.
755 fn render_select(&self, options: &[(String, String)], value: &str, multiple: bool) -> String {
756 let multiple_attr = if multiple { " multiple" } else { "" };
757 let required = if self.required { " required" } else { "" };
758 let mut s = format!(
759 "<select name=\"{name}\"{multiple_attr}{required}>",
760 name = html_escape(&self.name),
761 );
762 if !multiple && !self.required {
763 s.push_str("<option value=\"\"></option>");
764 }
765 for (val, label) in options {
766 let selected = if val == value { " selected" } else { "" };
767 s.push_str(&format!(
768 "<option value=\"{v}\"{selected}>{l}</option>",
769 v = html_escape(val),
770 l = html_escape(label),
771 ));
772 }
773 s.push_str("</select>");
774 s
775 }
776}
777
778/// `IntegerFormat` is a private validator used by `Field::integer`.
779/// Not exported as a builder method because every numeric field
780/// already gets it.
781struct IntegerFormat;
782impl Validator for IntegerFormat {
783 fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
784 value
785 .parse::<i64>()
786 .map(|_| ())
787 .map_err(|_| format!("{field_name} must be a whole number"))
788 }
789}
790
791/// `FloatFormat` is the float-field counterpart. Accepts anything
792/// that parses as `f64`, which includes integer literals like `"42"`
793/// — JS's `parseFloat` does the same.
794struct FloatFormat;
795impl Validator for FloatFormat {
796 fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
797 value
798 .parse::<f64>()
799 .map(|_| ())
800 .map_err(|_| format!("{field_name} must be a number"))
801 }
802}
803
804// =========================================================================
805// HTML escaping. Inline so the module doesn't pull in an extra crate.
806// Covers the five chars the OWASP cheat sheet flags.
807// =========================================================================
808
809fn html_escape(input: &str) -> String {
810 let mut out = String::with_capacity(input.len());
811 for ch in input.chars() {
812 match ch {
813 '&' => out.push_str("&"),
814 '<' => out.push_str("<"),
815 '>' => out.push_str(">"),
816 '"' => out.push_str("""),
817 '\'' => out.push_str("'"),
818 other => out.push(other),
819 }
820 }
821 out
822}
823
824// =========================================================================
825// gaps2 #19 — `Form<T>` axum extractor + `FormErrors` lifter
826//
827// The architectural rule (per gaps2 #19's spec): validation errors
828// originate at the ORM's `WriteError`. Every surface MAPS them, none
829// REDEFINES them. `ValidationErrors` is the form-specific producer;
830// `WriteError::Multiple` is the unified consumer. `FormErrors` is a
831// thin wrapper around `WriteError` that adds the
832// template-friendly flat view (`errors.name` → first error string).
833// =========================================================================
834
835use crate::orm::write::WriteError;
836
837/// Form-validation error envelope. Wraps the ORM's `WriteError` so
838/// every surface (REST 400 bodies, admin form spans, HTML form
839/// renders) sees the same structured shape. The template helper
840/// `as_template_ctx` produces the flat single-string-per-field view
841/// that most form templates ask for.
842///
843/// Not `Clone` because `WriteError` carries a `sqlx::Error` variant
844/// that's also not Clone. If you need a cheap copyable bundle of
845/// rendered messages, use [`Self::as_template_ctx`] which returns
846/// an owned `serde_json::Map`.
847#[derive(Debug)]
848pub struct FormErrors {
849 inner: WriteError,
850 /// The raw form pairs the user submitted, captured before
851 /// validation ran. Lets the handler re-render the form template
852 /// pre-filled with what the user typed — see
853 /// [`Self::raw_values`] and [`Self::raw_as_json`]. The
854 /// extractor (`Form::from_request`) carries this through
855 /// automatically; the `From<ValidationErrors>` path leaves it
856 /// empty (no raw input was ever in scope), which is the right
857 /// default for handlers that build a `FormErrors` from scratch
858 /// for ad-hoc errors.
859 raw: HashMap<String, String>,
860}
861
862impl FormErrors {
863 /// Wrap any [`WriteError`]. Use [`From`] for free conversion in
864 /// `?` chains. The raw values default to empty — call
865 /// [`Self::with_raw`] when you have the submitted pairs in
866 /// scope (typically only inside an axum extractor).
867 pub fn new(err: WriteError) -> Self {
868 Self {
869 inner: err,
870 raw: HashMap::new(),
871 }
872 }
873
874 /// Construct a `FormErrors` carrying both the validation
875 /// failure AND the raw form pairs the user submitted. The raw
876 /// pairs let the handler re-render the form pre-filled with
877 /// what the user typed instead of falling back to
878 /// `T::default()` (which loses every keystroke).
879 pub fn with_raw(err: WriteError, raw: HashMap<String, String>) -> Self {
880 Self { inner: err, raw }
881 }
882
883 /// Borrow the raw form pairs the user submitted, if the
884 /// extractor captured them. Empty when the [`FormErrors`] was
885 /// constructed via [`Self::new`] or any `From` impl that
886 /// doesn't see the request body.
887 pub fn raw_values(&self) -> &HashMap<String, String> {
888 &self.raw
889 }
890
891 /// JSON-shaped view of the raw values, ready to drop straight
892 /// into a template context as the `form` key so existing
893 /// `{{ form.<field> }}` references repopulate the user's
894 /// input. The map is `String → String` so every value
895 /// serialises to a JSON string — templates that need typed
896 /// access should call [`Self::raw_values`] and convert per
897 /// field.
898 pub fn raw_as_json(&self) -> serde_json::Value {
899 serde_json::Value::Object(
900 self.raw
901 .iter()
902 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
903 .collect(),
904 )
905 }
906
907 /// Borrow the underlying [`WriteError`] — keeps every accessor
908 /// available (`field_errors()`, `non_field_errors()`,
909 /// `error_code()`).
910 pub fn as_write_error(&self) -> &WriteError {
911 &self.inner
912 }
913
914 /// Move out the underlying [`WriteError`] (e.g. to feed a
915 /// REST-style error-body builder).
916 pub fn into_write_error(self) -> WriteError {
917 self.inner
918 }
919
920 /// Per-field error map — see [`WriteError::field_errors`].
921 pub fn field_errors(&self) -> std::collections::BTreeMap<String, Vec<String>> {
922 self.inner.field_errors()
923 }
924
925 /// Cross-field / non-field error list — see
926 /// [`WriteError::non_field_errors`].
927 pub fn non_field_errors(&self) -> Vec<String> {
928 self.inner.non_field_errors()
929 }
930
931 /// Template-friendly flat view: each field maps to its FIRST
932 /// error message (string), plus the FIRST non-field error under
933 /// the `form` key. Renders directly under the `errors` context
934 /// key — templates write `{{ errors.name }}` or
935 /// `{% if errors.form %}`.
936 ///
937 /// For templates that need to render EVERY error per field
938 /// (rare), call [`field_errors`] / [`non_field_errors`]
939 /// directly and pass the maps as-is.
940 pub fn as_template_ctx(&self) -> serde_json::Map<String, serde_json::Value> {
941 let mut out = serde_json::Map::new();
942 for (key, msgs) in self.field_errors() {
943 if let Some(first) = msgs.into_iter().next() {
944 out.insert(key, serde_json::Value::String(first));
945 }
946 }
947 if let Some(first) = self.non_field_errors().into_iter().next() {
948 out.insert("form".to_string(), serde_json::Value::String(first));
949 }
950 out
951 }
952
953 /// Render `template` with this failed submission bound into scope
954 /// and return the complete HTTP response. The one-liner for a form
955 /// handler's `Err` arm:
956 ///
957 /// ```ignore
958 /// let msg = match form.into_result() {
959 /// Ok(v) => v,
960 /// Err(errs) => return Ok(errs.render("contact.html")),
961 /// };
962 /// ```
963 ///
964 /// What the template sees:
965 ///
966 /// - `form` — the raw pairs the user submitted, so
967 /// `{{ form.<field> }}` repopulates every keystroke.
968 /// - `errors` — the flat per-field view from
969 /// [`Self::as_template_ctx`], plus a default form-level summary
970 /// under `errors.form` ("Please fix the highlighted fields and
971 /// try again.") when no non-field error supplied one — every
972 /// form page wants the banner, so the framework defaults it.
973 /// - Anything ambient (`csrf_token` / `csrf_input` / `user`) via
974 /// the normal render merge.
975 ///
976 /// Status is `422 Unprocessable Entity`. A template failure
977 /// returns a plain 500 carrying the render error. Extra context
978 /// keys (page flags, chrome): [`Self::render_with`].
979 pub fn render(&self, template: &str) -> axum::response::Response {
980 self.render_with(template, serde_json::Map::new())
981 }
982
983 /// [`Self::render`] plus caller-supplied top-level context keys.
984 /// `extra` wins over the `form` / `errors` bindings on key
985 /// collision — the caller is more specific than the default.
986 pub fn render_with(
987 &self,
988 template: &str,
989 extra: serde_json::Map<String, serde_json::Value>,
990 ) -> axum::response::Response {
991 use axum::response::IntoResponse;
992
993 let mut errors = self.as_template_ctx();
994 errors.entry("form".to_string()).or_insert_with(|| {
995 serde_json::Value::String(
996 "Please fix the highlighted fields and try again.".to_string(),
997 )
998 });
999
1000 let mut ctx = serde_json::Map::new();
1001 ctx.insert("form".to_string(), self.raw_as_json());
1002 ctx.insert("errors".to_string(), serde_json::Value::Object(errors));
1003 for (k, v) in extra {
1004 ctx.insert(k, v);
1005 }
1006
1007 match crate::templates::render(template, &serde_json::Value::Object(ctx)) {
1008 Ok(html) => (
1009 axum::http::StatusCode::UNPROCESSABLE_ENTITY,
1010 axum::response::Html(html),
1011 )
1012 .into_response(),
1013 Err(e) => {
1014 // The template name and raw minijinja error can carry
1015 // internal paths / line numbers — log them server-side,
1016 // never disclose them to the client. Mirrors the 500
1017 // handler's generic-body-plus-tracing pattern in
1018 // `errors.rs::render_500`.
1019 tracing::error!("form re-render failed for `{template}`: {e}");
1020 (
1021 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1022 "Internal Server Error",
1023 )
1024 .into_response()
1025 }
1026 }
1027 }
1028}
1029
1030impl std::fmt::Display for FormErrors {
1031 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1032 write!(f, "{}", self.inner)
1033 }
1034}
1035
1036impl std::error::Error for FormErrors {}
1037
1038impl From<WriteError> for FormErrors {
1039 fn from(e: WriteError) -> Self {
1040 Self::new(e)
1041 }
1042}
1043
1044/// Lift the form-primitive [`ValidationErrors`] into the canonical
1045/// [`WriteError`]. Each per-field message becomes a
1046/// `WriteError::Validator { field, message }`; non-field messages
1047/// become an `Anonymous` validator carrying the bare message.
1048/// Wrapped under `WriteError::Multiple` when there's more than one.
1049impl From<ValidationErrors> for WriteError {
1050 fn from(e: ValidationErrors) -> Self {
1051 let mut out: Vec<WriteError> = Vec::new();
1052 for (field, msgs) in e.fields {
1053 for message in msgs {
1054 out.push(WriteError::Validator {
1055 field: field.clone(),
1056 message,
1057 });
1058 }
1059 }
1060 for message in e.non_field {
1061 out.push(WriteError::Validator {
1062 field: String::new(),
1063 message,
1064 });
1065 }
1066 if out.len() == 1 {
1067 out.into_iter().next().expect("len == 1")
1068 } else {
1069 WriteError::Multiple { errors: out }
1070 }
1071 }
1072}
1073
1074impl From<ValidationErrors> for FormErrors {
1075 fn from(e: ValidationErrors) -> Self {
1076 Self::new(e.into())
1077 }
1078}
1079
1080/// Axum extractor that validates a form body before the handler
1081/// runs. On extraction success the wrapped result is
1082/// `Ok(T)` (the validated struct); on validation failure the
1083/// wrapped result is `Err(FormErrors)`. The HTTP layer never
1084/// rejects — handlers ALWAYS see a `Form<T>` and decide what to
1085/// render via [`Self::into_result`].
1086///
1087/// ```ignore
1088/// use umbral::forms::Form;
1089///
1090/// pub async fn submit(form: Form<ContactForm>) -> impl IntoResponse {
1091/// match form.into_result() {
1092/// Ok(valid) => persist_and_redirect(valid).await,
1093/// Err(errs) => render_form_with_errors(errs),
1094/// }
1095/// }
1096/// ```
1097///
1098/// The "always wrap, handler unwraps" shape (vs. axum's rejection-
1099/// type pattern) lets the handler render the form template with
1100/// the user's original input AND the per-field errors in one place
1101/// — no double-render dance, no rejection-type IntoResponse impl
1102/// to write per form.
1103pub struct Form<T> {
1104 inner: Result<T, FormErrors>,
1105}
1106
1107impl<T> Form<T> {
1108 /// Construct a `Form<T>` carrying a validated value.
1109 pub fn valid(value: T) -> Self {
1110 Self { inner: Ok(value) }
1111 }
1112
1113 /// Construct a `Form<T>` carrying validation errors.
1114 pub fn invalid(errors: FormErrors) -> Self {
1115 Self { inner: Err(errors) }
1116 }
1117
1118 /// Move the wrapped `Result` out. Handlers branch on this.
1119 pub fn into_result(self) -> Result<T, FormErrors> {
1120 self.inner
1121 }
1122
1123 /// Borrow the wrapped `Result` for inspection without consuming.
1124 pub fn as_result(&self) -> Result<&T, &FormErrors> {
1125 self.inner.as_ref()
1126 }
1127}
1128
1129impl<T, S> axum::extract::FromRequest<S> for Form<T>
1130where
1131 T: FormValidate + serde::de::DeserializeOwned + Send + 'static,
1132 S: Send + Sync,
1133{
1134 type Rejection = axum::response::Response;
1135
1136 async fn from_request(
1137 req: axum::extract::Request,
1138 _state: &S,
1139 ) -> Result<Self, Self::Rejection> {
1140 use axum::body::to_bytes;
1141 use axum::http::StatusCode;
1142 use axum::response::IntoResponse;
1143
1144 // BROKEN-8: this extractor only understands
1145 // `application/x-www-form-urlencoded`. A client POSTing JSON or
1146 // multipart used to have its body parse to an empty map and then
1147 // get a wall of "field required" errors — mis-diagnosing a wrong
1148 // Content-Type as missing fields. Reject a present-but-wrong
1149 // Content-Type up front with 415, like axum's own `Form`.
1150 let content_type = req
1151 .headers()
1152 .get(axum::http::header::CONTENT_TYPE)
1153 .and_then(|v| v.to_str().ok())
1154 .map(|s| s.to_ascii_lowercase());
1155 if let Some(ct) = &content_type
1156 && !ct.starts_with("application/x-www-form-urlencoded")
1157 {
1158 return Err((
1159 StatusCode::UNSUPPORTED_MEDIA_TYPE,
1160 "this endpoint expects application/x-www-form-urlencoded form data",
1161 )
1162 .into_response());
1163 }
1164
1165 // Read the body up to the CONFIGURED limit. Defaults to 16 MiB
1166 // (`Settings::max_form_body_bytes`); set `UMBRAL_MAX_FORM_BODY_BYTES`, or
1167 // `0` to disable the cap entirely. Buffering an unbounded urlencoded
1168 // body is a DoS risk, so a cap is the default — but it's no longer
1169 // hardcoded, and `0` removes it for dev / large forms.
1170 const FALLBACK_MAX_FORM_BODY: usize = 16 * 1024 * 1024;
1171 let max_body = match crate::settings::get_opt() {
1172 Some(s) => match s.max_form_body_bytes {
1173 None | Some(0) => usize::MAX, // explicitly disabled = no cap
1174 Some(n) => n,
1175 },
1176 None => FALLBACK_MAX_FORM_BODY, // Settings not published (low-level tests)
1177 };
1178 let bytes = match to_bytes(req.into_body(), max_body).await {
1179 Ok(b) => b,
1180 Err(_) => {
1181 return Err((
1182 StatusCode::PAYLOAD_TOO_LARGE,
1183 "form body exceeds the configured limit (Settings::max_form_body_bytes)",
1184 )
1185 .into_response());
1186 }
1187 };
1188
1189 // Parse x-www-form-urlencoded. We decode into an ORDERED list of
1190 // `(key, value)` pairs first — NOT straight into a
1191 // `HashMap<String, String>` — because a map keeps only the LAST
1192 // value for a repeated key, so a multi-select
1193 // (`roles=admin&roles=editor`) would silently lose every value but
1194 // the last. Repeated keys are collapsed by joining their values
1195 // with `,`, which is exactly the CSV shape the multi-value parsers
1196 // split back apart (`orm::multichoice` for `MultiChoice<E>`,
1197 // `forms_runtime::parse_multi_ids` for `M2M<T>`, both `.split(',')`).
1198 // Single-value keys are inserted verbatim, so the String->String
1199 // contract every `#[derive(Form)]` impl relies on is unchanged.
1200 //
1201 // An empty body parses to an empty list (Ok) — `FormValidate::validate`
1202 // then sees every field as missing and surfaces the right per-field
1203 // "required" errors. BROKEN-8: a genuinely MALFORMED body must not
1204 // be swallowed into an empty map (that re-runs as bogus "field
1205 // required" errors); surface it as a 400 naming the parse failure.
1206 let ordered: Vec<(String, String)> = match serde_urlencoded::from_bytes(&bytes) {
1207 Ok(pairs) => pairs,
1208 Err(e) => {
1209 return Err((
1210 StatusCode::BAD_REQUEST,
1211 format!("malformed urlencoded form body: {e}"),
1212 )
1213 .into_response());
1214 }
1215 };
1216 let mut pairs: std::collections::HashMap<String, String> =
1217 std::collections::HashMap::with_capacity(ordered.len());
1218 for (key, value) in ordered {
1219 match pairs.entry(key) {
1220 std::collections::hash_map::Entry::Occupied(mut slot) => {
1221 let joined = slot.get_mut();
1222 joined.push(',');
1223 joined.push_str(&value);
1224 }
1225 std::collections::hash_map::Entry::Vacant(slot) => {
1226 slot.insert(value);
1227 }
1228 }
1229 }
1230
1231 // Run validation. On success, we've already proven the data
1232 // fits T's shape — return Ok(T). On failure, lift the
1233 // ValidationErrors to a FormErrors AND attach the raw
1234 // pairs so the handler can render the template pre-filled
1235 // with what the user typed. Without this the user loses
1236 // every keystroke on validation failure — see gaps2 #19
1237 // follow-up commit for the bug screenshot that prompted
1238 // this change.
1239 match T::validate(&pairs).await {
1240 Ok(value) => Ok(Self::valid(value)),
1241 Err(errs) => {
1242 let write_err: WriteError = errs.into();
1243 Ok(Self::invalid(FormErrors::with_raw(write_err, pairs)))
1244 }
1245 }
1246 }
1247}
1248
1249// =========================================================================
1250// Tests live inline because the surface is pure (no DB, no async).
1251// =========================================================================
1252
1253#[cfg(test)]
1254mod tests {
1255 use super::*;
1256
1257 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1258 pairs
1259 .iter()
1260 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1261 .collect()
1262 }
1263
1264 #[test]
1265 fn required_field_rejects_empty_value() {
1266 let f = Field::text("username");
1267 let mut errs = ValidationErrors::new();
1268 let form = data(&[("username", "")]);
1269 f.validate(form.get("username").unwrap(), &mut errs);
1270 assert!(errs.fields.contains_key("username"));
1271 assert!(errs.fields["username"][0].contains("required"));
1272 }
1273
1274 #[test]
1275 fn optional_field_with_empty_value_passes() {
1276 let f = Field::text("bio").optional();
1277 let mut errs = ValidationErrors::new();
1278 f.validate("", &mut errs);
1279 assert!(errs.is_empty());
1280 }
1281
1282 #[test]
1283 fn min_max_length_combine_on_one_field() {
1284 let f = Field::text("title").min_length(3).max_length(5);
1285 let mut errs = ValidationErrors::new();
1286 f.validate("ab", &mut errs);
1287 assert!(errs.fields["title"][0].contains("at least 3"));
1288
1289 let mut errs = ValidationErrors::new();
1290 f.validate("toolong", &mut errs);
1291 assert!(errs.fields["title"][0].contains("at most 5"));
1292
1293 let mut errs = ValidationErrors::new();
1294 f.validate("abcd", &mut errs);
1295 assert!(errs.is_empty());
1296 }
1297
1298 #[test]
1299 fn integer_field_rejects_non_numeric_input() {
1300 let f = Field::integer("age");
1301 let mut errs = ValidationErrors::new();
1302 f.validate("twelve", &mut errs);
1303 assert!(errs.fields["age"][0].contains("whole number"));
1304
1305 let mut errs = ValidationErrors::new();
1306 f.validate("42", &mut errs);
1307 assert!(errs.is_empty());
1308 }
1309
1310 #[test]
1311 fn email_field_runs_the_built_in_format_check() {
1312 let f = Field::email("email");
1313
1314 let mut errs = ValidationErrors::new();
1315 f.validate("not-an-email", &mut errs);
1316 assert!(!errs.is_empty());
1317
1318 let mut errs = ValidationErrors::new();
1319 f.validate("alice@example.com", &mut errs);
1320 assert!(errs.is_empty());
1321
1322 // Local part missing
1323 let mut errs = ValidationErrors::new();
1324 f.validate("@example.com", &mut errs);
1325 assert!(!errs.is_empty());
1326
1327 // Domain missing a dot
1328 let mut errs = ValidationErrors::new();
1329 f.validate("alice@example", &mut errs);
1330 assert!(!errs.is_empty());
1331 }
1332
1333 #[test]
1334 fn non_field_errors_propagate_through_into_result() {
1335 let mut errs = ValidationErrors::new();
1336 errs.add_non_field("passwords do not match");
1337 let result = errs.into_result();
1338 match result {
1339 Err(e) => {
1340 assert_eq!(e.non_field.len(), 1);
1341 assert!(e.non_field[0].contains("passwords"));
1342 }
1343 Ok(_) => panic!("non-field error should fail into_result"),
1344 }
1345 }
1346
1347 #[test]
1348 fn render_html_escapes_user_input_against_xss() {
1349 let f = Field::text("title");
1350 let rendered = f.render_html("<script>alert(1)</script>");
1351 assert!(rendered.contains("<script>"));
1352 assert!(!rendered.contains("<script>alert"));
1353 assert!(rendered.contains("name=\"title\""));
1354 assert!(rendered.contains("required"));
1355 }
1356
1357 #[test]
1358 fn render_html_emits_the_right_input_type_per_field_kind() {
1359 assert!(Field::text("a").render_html("").contains("type=\"text\""));
1360 assert!(Field::email("a").render_html("").contains("type=\"email\""));
1361 assert!(
1362 Field::password("a")
1363 .render_html("")
1364 .contains("type=\"password\"")
1365 );
1366 assert!(
1367 Field::integer("a")
1368 .render_html("")
1369 .contains("type=\"number\"")
1370 );
1371 assert!(
1372 Field::boolean("a")
1373 .render_html("")
1374 .contains("type=\"checkbox\"")
1375 );
1376 }
1377
1378 #[test]
1379 fn boolean_field_renders_checked_when_value_is_truthy() {
1380 let f = Field::boolean("is_admin");
1381 assert!(f.render_html("true").contains(" checked"));
1382 assert!(f.render_html("on").contains(" checked"));
1383 assert!(f.render_html("1").contains(" checked"));
1384 assert!(!f.render_html("").contains(" checked"));
1385 assert!(!f.render_html("false").contains(" checked"));
1386 }
1387
1388 /// Demo composition: a tiny LoginForm built from primitive
1389 /// fields. Stands in for what a `#[derive(Form)]` would produce.
1390 /// Validates a HashMap, returns a typed struct, accumulates
1391 /// every field's errors.
1392 #[derive(Debug, PartialEq, Eq)]
1393 struct LoginForm {
1394 username: String,
1395 password: String,
1396 }
1397
1398 impl LoginForm {
1399 fn validate(form: &HashMap<String, String>) -> Result<Self, ValidationErrors> {
1400 let username_field = Field::text("username").min_length(3).max_length(150);
1401 let password_field = Field::password("password").min_length(8);
1402 let mut errs = ValidationErrors::new();
1403 let username = form.get("username").cloned().unwrap_or_default();
1404 let password = form.get("password").cloned().unwrap_or_default();
1405 username_field.validate(&username, &mut errs);
1406 password_field.validate(&password, &mut errs);
1407 errs.into_result()?;
1408 Ok(Self { username, password })
1409 }
1410 }
1411
1412 #[test]
1413 fn login_form_demo_validates_happy_path() {
1414 let input = data(&[("username", "alice"), ("password", "hunter2-stronger")]);
1415 let form = LoginForm::validate(&input).expect("happy path");
1416 assert_eq!(form.username, "alice");
1417 assert_eq!(form.password, "hunter2-stronger");
1418 }
1419
1420 #[test]
1421 fn login_form_demo_collects_every_field_error_at_once() {
1422 let input = data(&[("username", "ab"), ("password", "short")]);
1423 let err = LoginForm::validate(&input).expect_err("both fields fail");
1424 assert!(err.fields.contains_key("username"));
1425 assert!(err.fields.contains_key("password"));
1426 assert!(err.fields["username"][0].contains("at least 3"));
1427 assert!(err.fields["password"][0].contains("at least 8"));
1428 }
1429
1430 // =====================================================================
1431 // gaps2 #19 follow-up — FormErrors carries the raw form pairs so the
1432 // handler can re-render the template pre-filled with what the user
1433 // typed instead of falling back to `T::default()` (which loses every
1434 // keystroke). Screenshot 2026-06-10 01-03-09 reported the data-loss
1435 // bug pre-fix.
1436 // =====================================================================
1437
1438 // =====================================================================
1439 // gaps2 #19 follow-up — regex / phone / url validators
1440 // =====================================================================
1441
1442 #[test]
1443 fn phone_field_accepts_e164_format() {
1444 let f = Field::phone("phone");
1445 let mut errs = ValidationErrors::new();
1446 f.validate("+14155551234", &mut errs);
1447 assert!(errs.is_empty(), "valid E.164 should pass: {:?}", errs);
1448 }
1449
1450 #[test]
1451 fn phone_field_rejects_local_only_format() {
1452 // The bug report case: "07065" got accepted because the
1453 // field had only `optional + max_length`. With `Field::phone`
1454 // (== `#[form(phone)]`) the E.164 regex rejects it.
1455 let f = Field::phone("phone");
1456 let mut errs = ValidationErrors::new();
1457 f.validate("07065", &mut errs);
1458 assert!(errs.fields.contains_key("phone"));
1459 assert!(
1460 errs.fields["phone"][0].contains("E.164"),
1461 "error message names the format: {:?}",
1462 errs.fields["phone"][0]
1463 );
1464 }
1465
1466 #[test]
1467 fn phone_field_rejects_letters_and_punctuation() {
1468 let f = Field::phone("phone");
1469 for bad in &["+1-415-555-1234", "+1 (415) 555 1234", "+1abc", "+0123"] {
1470 let mut errs = ValidationErrors::new();
1471 f.validate(bad, &mut errs);
1472 assert!(
1473 errs.fields.contains_key("phone"),
1474 "should reject `{bad}`: {:?}",
1475 errs.fields
1476 );
1477 }
1478 }
1479
1480 #[test]
1481 fn url_field_accepts_http_and_https_only() {
1482 let f = Field::url("homepage");
1483 for good in &["https://example.com", "http://example.com/path?q=1"] {
1484 let mut errs = ValidationErrors::new();
1485 f.validate(good, &mut errs);
1486 assert!(errs.is_empty(), "should accept `{good}`: {:?}", errs);
1487 }
1488 for bad in &["ftp://example.com", "mailto:a@b.c", "example.com"] {
1489 let mut errs = ValidationErrors::new();
1490 f.validate(bad, &mut errs);
1491 assert!(
1492 errs.fields.contains_key("homepage"),
1493 "should reject `{bad}`: {:?}",
1494 errs.fields
1495 );
1496 }
1497 }
1498
1499 #[test]
1500 fn regex_validator_substitutes_field_in_message() {
1501 // {field} placeholder gets the actual field name — useful
1502 // for reusable messages across multiple forms.
1503 let f = Field::text("invoice_id")
1504 .regex(r"^INV-\d{6}$", "{field} must match the invoice pattern");
1505 let mut errs = ValidationErrors::new();
1506 f.validate("not-an-invoice", &mut errs);
1507 assert_eq!(
1508 errs.fields["invoice_id"][0],
1509 "invoice_id must match the invoice pattern"
1510 );
1511 }
1512
1513 #[test]
1514 fn regex_validator_composes_with_required_and_max_length() {
1515 // Order: Required runs FIRST (empty → "is required"),
1516 // then max_length, then regex. An empty value should
1517 // surface the "required" error, not "doesn't match pattern".
1518 let f = Field::text("code")
1519 .max_length(8)
1520 .regex(r"^[A-Z]{3}$", "{field} must be 3 uppercase letters");
1521
1522 let mut errs = ValidationErrors::new();
1523 f.validate("", &mut errs);
1524 assert!(
1525 errs.fields["code"][0].contains("required"),
1526 "empty surfaces required error first: {:?}",
1527 errs.fields["code"][0]
1528 );
1529
1530 let mut errs = ValidationErrors::new();
1531 f.validate("HELLO", &mut errs);
1532 assert!(
1533 errs.fields["code"][0].contains("3 uppercase"),
1534 "regex error fires when value is present but malformed: {:?}",
1535 errs.fields["code"][0]
1536 );
1537 }
1538
1539 #[test]
1540 fn form_errors_with_raw_round_trips_the_submitted_pairs() {
1541 let mut raw = HashMap::new();
1542 raw.insert("name".to_string(), "Bella Verifier".to_string());
1543 raw.insert("email".to_string(), "bella@invalid".to_string());
1544 raw.insert("phone".to_string(), "none".to_string());
1545
1546 let errs = FormErrors::with_raw(
1547 WriteError::Validator {
1548 field: "email".to_string(),
1549 message: "email's domain must contain at least one `.`".to_string(),
1550 },
1551 raw.clone(),
1552 );
1553
1554 // Raw values survive untouched.
1555 assert_eq!(
1556 errs.raw_values().get("name").map(|s| s.as_str()),
1557 Some("Bella Verifier"),
1558 );
1559 assert_eq!(
1560 errs.raw_values().get("phone").map(|s| s.as_str()),
1561 Some("none"),
1562 );
1563
1564 // JSON shape is a flat `{ field: "literal user input" }` map,
1565 // ready to drop straight into a template ctx as `form` so
1566 // `{{ form.name }}` repopulates.
1567 let json = errs.raw_as_json();
1568 let obj = json.as_object().expect("raw_as_json is an object");
1569 assert_eq!(
1570 obj.get("name").and_then(|v| v.as_str()),
1571 Some("Bella Verifier")
1572 );
1573 assert_eq!(obj.get("phone").and_then(|v| v.as_str()), Some("none"));
1574 }
1575
1576 #[test]
1577 fn field_name_is_html_escaped_in_rendered_markup() {
1578 // Field names are static struct identifiers in the derive-macro
1579 // norm, but `Field::text(user_supplied_name)` is a public path.
1580 // The `name` attribute must be HTML-escaped so a crafted name
1581 // can't break out of the attribute and inject markup/handlers.
1582 let malicious = "x\" onfocus=\"alert(1)";
1583
1584 // Text input
1585 let out = Field::text(malicious).render_html("");
1586 assert!(
1587 !out.contains("onfocus=\"alert(1)"),
1588 "text input leaked an unescaped name attribute: {out}"
1589 );
1590 assert!(out.contains("""), "name should be escaped: {out}");
1591
1592 // Textarea
1593 let mut ta = Field::text(malicious);
1594 ta.kind = InputKind::Textarea;
1595 let out = ta.render_html("");
1596 assert!(
1597 !out.contains("onfocus=\"alert(1)"),
1598 "textarea leaked an unescaped name attribute: {out}"
1599 );
1600
1601 // Checkbox
1602 let mut cb = Field::text(malicious);
1603 cb.kind = InputKind::Checkbox;
1604 let out = cb.render_html("");
1605 assert!(
1606 !out.contains("onfocus=\"alert(1)"),
1607 "checkbox leaked an unescaped name attribute: {out}"
1608 );
1609
1610 // Select
1611 let mut sel = Field::text(malicious);
1612 sel.kind = InputKind::Select;
1613 let out = sel.render_html("");
1614 assert!(
1615 !out.contains("onfocus=\"alert(1)"),
1616 "select leaked an unescaped name attribute: {out}"
1617 );
1618
1619 // File
1620 let mut file = Field::text(malicious);
1621 file.kind = InputKind::File;
1622 let out = file.render_html("");
1623 assert!(
1624 !out.contains("onfocus=\"alert(1)"),
1625 "file input leaked an unescaped name attribute: {out}"
1626 );
1627 }
1628
1629 #[tokio::test]
1630 async fn form_render_failure_does_not_leak_template_name_or_error() {
1631 // A form-template render failure must not disclose the template
1632 // name or the raw minijinja error (which can carry file paths /
1633 // line numbers) to the client. The client gets a generic 500;
1634 // the detail is logged server-side.
1635 let errs = FormErrors::new(WriteError::Validator {
1636 field: "email".to_string(),
1637 message: "invalid".to_string(),
1638 });
1639
1640 // A template name that doesn't exist forces `render` down its
1641 // error branch (missing template, or engine-not-initialised —
1642 // either way, `Err`).
1643 let secret_name = "signup/__nonexistent_secret_path__.html";
1644 let resp = errs.render(secret_name);
1645
1646 assert_eq!(resp.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR);
1647 let body = axum::body::to_bytes(resp.into_body(), 1 << 20)
1648 .await
1649 .expect("collect body");
1650 let text = String::from_utf8_lossy(&body);
1651 assert!(
1652 !text.contains(secret_name),
1653 "500 body leaked the template name: {text}"
1654 );
1655 assert!(
1656 !text.contains("form re-render failed"),
1657 "500 body leaked internal render-error detail: {text}"
1658 );
1659 }
1660
1661 // A form the extractor can validate. `roles` is a multi-value field:
1662 // the extractor must hand `validate` the CSV-joined value of every
1663 // repeated `roles=` key, not just the last one.
1664 // `serde::Deserialize` only satisfies the extractor's trait bound;
1665 // the extractor itself no longer deserializes the body into `T`.
1666 #[derive(Debug, PartialEq, Eq, serde::Deserialize)]
1667 struct MultiSelectForm {
1668 roles: Vec<String>,
1669 }
1670
1671 #[async_trait::async_trait]
1672 impl FormValidate for MultiSelectForm {
1673 fn fields() -> Vec<Field> {
1674 vec![Field::text("roles").optional()]
1675 }
1676
1677 async fn validate(data: &HashMap<String, String>) -> Result<Self, ValidationErrors> {
1678 // Mirrors how the MultiChoice / M2M runtime splits the joined
1679 // value back into its members (`.split(',')`, empties dropped).
1680 let roles = data
1681 .get("roles")
1682 .map(|raw| {
1683 raw.split(',')
1684 .map(str::trim)
1685 .filter(|s| !s.is_empty())
1686 .map(ToString::to_string)
1687 .collect::<Vec<_>>()
1688 })
1689 .unwrap_or_default();
1690 Ok(Self { roles })
1691 }
1692 }
1693
1694 #[tokio::test]
1695 async fn form_extractor_keeps_every_value_of_a_repeated_key() {
1696 use axum::extract::FromRequest;
1697
1698 // A multi-select posts the same key once per selected option.
1699 // Parsing straight into a HashMap<String,String> would keep only
1700 // "editor"; the extractor must preserve both as a CSV value.
1701 let req = axum::http::Request::builder()
1702 .method("POST")
1703 .header(
1704 axum::http::header::CONTENT_TYPE,
1705 "application/x-www-form-urlencoded",
1706 )
1707 .body(axum::body::Body::from("roles=admin&roles=editor"))
1708 .expect("build request");
1709
1710 let form = Form::<MultiSelectForm>::from_request(req, &())
1711 .await
1712 .expect("extractor succeeds");
1713
1714 let value = form.into_result().expect("valid");
1715 assert_eq!(
1716 value.roles,
1717 vec!["admin".to_string(), "editor".to_string()],
1718 "repeated urlencoded key lost values — only the last survived"
1719 );
1720 }
1721
1722 #[test]
1723 fn form_errors_new_defaults_raw_to_empty_for_ad_hoc_construction() {
1724 // FormErrors::new doesn't see the request body — common shape
1725 // for handlers that construct an ad-hoc error after the
1726 // extractor ran. Raw map MUST default to empty, not panic.
1727 let errs = FormErrors::new(WriteError::Validator {
1728 field: "form".to_string(),
1729 message: "rate limited".to_string(),
1730 });
1731 assert!(errs.raw_values().is_empty());
1732 // JSON shape stays a valid empty object — template ctx
1733 // doesn't crash when nothing was submitted.
1734 let json = errs.raw_as_json();
1735 assert!(json.as_object().expect("object").is_empty());
1736 }
1737}