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