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 = 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 required = if self.required { " required" } else { "" };
685 match self.kind {
686 InputKind::Textarea => format!(
687 "<textarea name=\"{name}\"{required}>{safe_value}</textarea>",
688 name = self.name,
689 ),
690 InputKind::Checkbox => {
691 let checked = if value == "true" || value == "on" || value == "1" {
692 " checked"
693 } else {
694 ""
695 };
696 format!(
697 "<input type=\"checkbox\" name=\"{name}\" value=\"true\"{checked}>",
698 name = self.name,
699 )
700 }
701 InputKind::Select => {
702 let mut s = format!(
703 "<select name=\"{name}\"{required}>",
704 name = self.name,
705 required = required
706 );
707 for (val, label) in &self.options {
708 let selected = if val == value { " selected" } else { "" };
709 s.push_str(&format!(
710 "<option value=\"{v}\"{selected}>{l}</option>",
711 v = html_escape(val),
712 l = html_escape(label),
713 ));
714 }
715 s.push_str("</select>");
716 s
717 }
718 InputKind::File => format!(
719 // No `value` attribute: browsers reject programmatic
720 // file-input values, and echoing the storage key into
721 // the markup would leak it. The prefill is intentionally
722 // dropped.
723 "<input type=\"file\" name=\"{name}\"{required}>",
724 name = self.name,
725 ),
726 other => format!(
727 "<input type=\"{ty}\" name=\"{name}\" value=\"{safe_value}\"{required}>",
728 ty = other.html_type(),
729 name = self.name,
730 ),
731 }
732 }
733
734 /// Async render entry point. `ModelChoice` / `ModelMultiChoice`
735 /// fetch their options first, then render the `<select>`; every
736 /// other kind defers to the sync `render_html`.
737 pub async fn render_html_async(&self, value: &str) -> String {
738 match self.kind {
739 InputKind::ModelChoice {
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, false)
747 }
748 InputKind::ModelMultiChoice {
749 target_table,
750 label_field,
751 ..
752 } => {
753 let options =
754 crate::orm::forms_runtime::fetch_model_options(target_table, label_field).await;
755 self.render_select(&options, value, true)
756 }
757 _ => self.render_html(value),
758 }
759 }
760
761 /// Shared `<select>` writer for `ModelChoice` / `ModelMultiChoice`.
762 /// `multiple` adds the `multiple` attribute. `selected` matches the
763 /// option value against the prefill `value`.
764 fn render_select(&self, options: &[(String, String)], value: &str, multiple: bool) -> String {
765 let multiple_attr = if multiple { " multiple" } else { "" };
766 let required = if self.required { " required" } else { "" };
767 let mut s = format!(
768 "<select name=\"{name}\"{multiple_attr}{required}>",
769 name = self.name,
770 );
771 if !multiple && !self.required {
772 s.push_str("<option value=\"\"></option>");
773 }
774 for (val, label) in options {
775 let selected = if val == value { " selected" } else { "" };
776 s.push_str(&format!(
777 "<option value=\"{v}\"{selected}>{l}</option>",
778 v = html_escape(val),
779 l = html_escape(label),
780 ));
781 }
782 s.push_str("</select>");
783 s
784 }
785}
786
787/// `IntegerFormat` is a private validator used by `Field::integer`.
788/// Not exported as a builder method because every numeric field
789/// already gets it.
790struct IntegerFormat;
791impl Validator for IntegerFormat {
792 fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
793 value
794 .parse::<i64>()
795 .map(|_| ())
796 .map_err(|_| format!("{field_name} must be a whole number"))
797 }
798}
799
800/// `FloatFormat` is the float-field counterpart. Accepts anything
801/// that parses as `f64`, which includes integer literals like `"42"`
802/// — JS's `parseFloat` does the same.
803struct FloatFormat;
804impl Validator for FloatFormat {
805 fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
806 value
807 .parse::<f64>()
808 .map(|_| ())
809 .map_err(|_| format!("{field_name} must be a number"))
810 }
811}
812
813// =========================================================================
814// HTML escaping. Inline so the module doesn't pull in an extra crate.
815// Covers the five chars the OWASP cheat sheet flags.
816// =========================================================================
817
818fn html_escape(input: &str) -> String {
819 let mut out = String::with_capacity(input.len());
820 for ch in input.chars() {
821 match ch {
822 '&' => out.push_str("&"),
823 '<' => out.push_str("<"),
824 '>' => out.push_str(">"),
825 '"' => out.push_str("""),
826 '\'' => out.push_str("'"),
827 other => out.push(other),
828 }
829 }
830 out
831}
832
833// =========================================================================
834// gaps2 #19 — `Form<T>` axum extractor + `FormErrors` lifter
835//
836// The architectural rule (per gaps2 #19's spec): validation errors
837// originate at the ORM's `WriteError`. Every surface MAPS them, none
838// REDEFINES them. `ValidationErrors` is the form-specific producer;
839// `WriteError::Multiple` is the unified consumer. `FormErrors` is a
840// thin wrapper around `WriteError` that adds the
841// template-friendly flat view (`errors.name` → first error string).
842// =========================================================================
843
844use crate::orm::write::WriteError;
845
846/// Form-validation error envelope. Wraps the ORM's `WriteError` so
847/// every surface (REST 400 bodies, admin form spans, HTML form
848/// renders) sees the same structured shape. The template helper
849/// `as_template_ctx` produces the flat single-string-per-field view
850/// that most form templates ask for.
851///
852/// Not `Clone` because `WriteError` carries a `sqlx::Error` variant
853/// that's also not Clone. If you need a cheap copyable bundle of
854/// rendered messages, use [`Self::as_template_ctx`] which returns
855/// an owned `serde_json::Map`.
856#[derive(Debug)]
857pub struct FormErrors {
858 inner: WriteError,
859 /// The raw form pairs the user submitted, captured before
860 /// validation ran. Lets the handler re-render the form template
861 /// pre-filled with what the user typed — see
862 /// [`Self::raw_values`] and [`Self::raw_as_json`]. The
863 /// extractor (`Form::from_request`) carries this through
864 /// automatically; the `From<ValidationErrors>` path leaves it
865 /// empty (no raw input was ever in scope), which is the right
866 /// default for handlers that build a `FormErrors` from scratch
867 /// for ad-hoc errors.
868 raw: HashMap<String, String>,
869}
870
871impl FormErrors {
872 /// Wrap any [`WriteError`]. Use [`From`] for free conversion in
873 /// `?` chains. The raw values default to empty — call
874 /// [`Self::with_raw`] when you have the submitted pairs in
875 /// scope (typically only inside an axum extractor).
876 pub fn new(err: WriteError) -> Self {
877 Self {
878 inner: err,
879 raw: HashMap::new(),
880 }
881 }
882
883 /// Construct a `FormErrors` carrying both the validation
884 /// failure AND the raw form pairs the user submitted. The raw
885 /// pairs let the handler re-render the form pre-filled with
886 /// what the user typed instead of falling back to
887 /// `T::default()` (which loses every keystroke).
888 pub fn with_raw(err: WriteError, raw: HashMap<String, String>) -> Self {
889 Self { inner: err, raw }
890 }
891
892 /// Borrow the raw form pairs the user submitted, if the
893 /// extractor captured them. Empty when the [`FormErrors`] was
894 /// constructed via [`Self::new`] or any `From` impl that
895 /// doesn't see the request body.
896 pub fn raw_values(&self) -> &HashMap<String, String> {
897 &self.raw
898 }
899
900 /// JSON-shaped view of the raw values, ready to drop straight
901 /// into a template context as the `form` key so existing
902 /// `{{ form.<field> }}` references repopulate the user's
903 /// input. The map is `String → String` so every value
904 /// serialises to a JSON string — templates that need typed
905 /// access should call [`Self::raw_values`] and convert per
906 /// field.
907 pub fn raw_as_json(&self) -> serde_json::Value {
908 serde_json::Value::Object(
909 self.raw
910 .iter()
911 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
912 .collect(),
913 )
914 }
915
916 /// Borrow the underlying [`WriteError`] — keeps every accessor
917 /// available (`field_errors()`, `non_field_errors()`,
918 /// `error_code()`).
919 pub fn as_write_error(&self) -> &WriteError {
920 &self.inner
921 }
922
923 /// Move out the underlying [`WriteError`] (e.g. to feed a
924 /// REST-style error-body builder).
925 pub fn into_write_error(self) -> WriteError {
926 self.inner
927 }
928
929 /// Per-field error map — see [`WriteError::field_errors`].
930 pub fn field_errors(&self) -> std::collections::BTreeMap<String, Vec<String>> {
931 self.inner.field_errors()
932 }
933
934 /// Cross-field / non-field error list — see
935 /// [`WriteError::non_field_errors`].
936 pub fn non_field_errors(&self) -> Vec<String> {
937 self.inner.non_field_errors()
938 }
939
940 /// Template-friendly flat view: each field maps to its FIRST
941 /// error message (string), plus the FIRST non-field error under
942 /// the `form` key. Renders directly under the `errors` context
943 /// key — templates write `{{ errors.name }}` or
944 /// `{% if errors.form %}`.
945 ///
946 /// For templates that need to render EVERY error per field
947 /// (rare), call [`field_errors`] / [`non_field_errors`]
948 /// directly and pass the maps as-is.
949 pub fn as_template_ctx(&self) -> serde_json::Map<String, serde_json::Value> {
950 let mut out = serde_json::Map::new();
951 for (key, msgs) in self.field_errors() {
952 if let Some(first) = msgs.into_iter().next() {
953 out.insert(key, serde_json::Value::String(first));
954 }
955 }
956 if let Some(first) = self.non_field_errors().into_iter().next() {
957 out.insert("form".to_string(), serde_json::Value::String(first));
958 }
959 out
960 }
961
962 /// Render `template` with this failed submission bound into scope
963 /// and return the complete HTTP response. The one-liner for a form
964 /// handler's `Err` arm:
965 ///
966 /// ```ignore
967 /// let msg = match form.into_result() {
968 /// Ok(v) => v,
969 /// Err(errs) => return Ok(errs.render("contact.html")),
970 /// };
971 /// ```
972 ///
973 /// What the template sees:
974 ///
975 /// - `form` — the raw pairs the user submitted, so
976 /// `{{ form.<field> }}` repopulates every keystroke.
977 /// - `errors` — the flat per-field view from
978 /// [`Self::as_template_ctx`], plus a default form-level summary
979 /// under `errors.form` ("Please fix the highlighted fields and
980 /// try again.") when no non-field error supplied one — every
981 /// form page wants the banner, so the framework defaults it.
982 /// - Anything ambient (`csrf_token` / `csrf_input` / `user`) via
983 /// the normal render merge.
984 ///
985 /// Status is `422 Unprocessable Entity`. A template failure
986 /// returns a plain 500 carrying the render error. Extra context
987 /// keys (page flags, chrome): [`Self::render_with`].
988 pub fn render(&self, template: &str) -> axum::response::Response {
989 self.render_with(template, serde_json::Map::new())
990 }
991
992 /// [`Self::render`] plus caller-supplied top-level context keys.
993 /// `extra` wins over the `form` / `errors` bindings on key
994 /// collision — the caller is more specific than the default.
995 pub fn render_with(
996 &self,
997 template: &str,
998 extra: serde_json::Map<String, serde_json::Value>,
999 ) -> axum::response::Response {
1000 use axum::response::IntoResponse;
1001
1002 let mut errors = self.as_template_ctx();
1003 errors.entry("form".to_string()).or_insert_with(|| {
1004 serde_json::Value::String(
1005 "Please fix the highlighted fields and try again.".to_string(),
1006 )
1007 });
1008
1009 let mut ctx = serde_json::Map::new();
1010 ctx.insert("form".to_string(), self.raw_as_json());
1011 ctx.insert("errors".to_string(), serde_json::Value::Object(errors));
1012 for (k, v) in extra {
1013 ctx.insert(k, v);
1014 }
1015
1016 match crate::templates::render(template, &serde_json::Value::Object(ctx)) {
1017 Ok(html) => (
1018 axum::http::StatusCode::UNPROCESSABLE_ENTITY,
1019 axum::response::Html(html),
1020 )
1021 .into_response(),
1022 Err(e) => (
1023 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1024 format!("form re-render failed for `{template}`: {e}"),
1025 )
1026 .into_response(),
1027 }
1028 }
1029}
1030
1031impl std::fmt::Display for FormErrors {
1032 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1033 write!(f, "{}", self.inner)
1034 }
1035}
1036
1037impl std::error::Error for FormErrors {}
1038
1039impl From<WriteError> for FormErrors {
1040 fn from(e: WriteError) -> Self {
1041 Self::new(e)
1042 }
1043}
1044
1045/// Lift the form-primitive [`ValidationErrors`] into the canonical
1046/// [`WriteError`]. Each per-field message becomes a
1047/// `WriteError::Validator { field, message }`; non-field messages
1048/// become an `Anonymous` validator carrying the bare message.
1049/// Wrapped under `WriteError::Multiple` when there's more than one.
1050impl From<ValidationErrors> for WriteError {
1051 fn from(e: ValidationErrors) -> Self {
1052 let mut out: Vec<WriteError> = Vec::new();
1053 for (field, msgs) in e.fields {
1054 for message in msgs {
1055 out.push(WriteError::Validator {
1056 field: field.clone(),
1057 message,
1058 });
1059 }
1060 }
1061 for message in e.non_field {
1062 out.push(WriteError::Validator {
1063 field: String::new(),
1064 message,
1065 });
1066 }
1067 if out.len() == 1 {
1068 out.into_iter().next().expect("len == 1")
1069 } else {
1070 WriteError::Multiple { errors: out }
1071 }
1072 }
1073}
1074
1075impl From<ValidationErrors> for FormErrors {
1076 fn from(e: ValidationErrors) -> Self {
1077 Self::new(e.into())
1078 }
1079}
1080
1081/// Axum extractor that validates a form body before the handler
1082/// runs. On extraction success the wrapped result is
1083/// `Ok(T)` (the validated struct); on validation failure the
1084/// wrapped result is `Err(FormErrors)`. The HTTP layer never
1085/// rejects — handlers ALWAYS see a `Form<T>` and decide what to
1086/// render via [`Self::into_result`].
1087///
1088/// ```ignore
1089/// use umbral::forms::Form;
1090///
1091/// pub async fn submit(form: Form<ContactForm>) -> impl IntoResponse {
1092/// match form.into_result() {
1093/// Ok(valid) => persist_and_redirect(valid).await,
1094/// Err(errs) => render_form_with_errors(errs),
1095/// }
1096/// }
1097/// ```
1098///
1099/// The "always wrap, handler unwraps" shape (vs. axum's rejection-
1100/// type pattern) lets the handler render the form template with
1101/// the user's original input AND the per-field errors in one place
1102/// — no double-render dance, no rejection-type IntoResponse impl
1103/// to write per form.
1104pub struct Form<T> {
1105 inner: Result<T, FormErrors>,
1106}
1107
1108impl<T> Form<T> {
1109 /// Construct a `Form<T>` carrying a validated value.
1110 pub fn valid(value: T) -> Self {
1111 Self { inner: Ok(value) }
1112 }
1113
1114 /// Construct a `Form<T>` carrying validation errors.
1115 pub fn invalid(errors: FormErrors) -> Self {
1116 Self { inner: Err(errors) }
1117 }
1118
1119 /// Move the wrapped `Result` out. Handlers branch on this.
1120 pub fn into_result(self) -> Result<T, FormErrors> {
1121 self.inner
1122 }
1123
1124 /// Borrow the wrapped `Result` for inspection without consuming.
1125 pub fn as_result(&self) -> Result<&T, &FormErrors> {
1126 self.inner.as_ref()
1127 }
1128}
1129
1130impl<T, S> axum::extract::FromRequest<S> for Form<T>
1131where
1132 T: FormValidate + serde::de::DeserializeOwned + Send + 'static,
1133 S: Send + Sync,
1134{
1135 type Rejection = axum::response::Response;
1136
1137 async fn from_request(
1138 req: axum::extract::Request,
1139 _state: &S,
1140 ) -> Result<Self, Self::Rejection> {
1141 use axum::body::to_bytes;
1142 use axum::http::StatusCode;
1143 use axum::response::IntoResponse;
1144
1145 // BROKEN-8: this extractor only understands
1146 // `application/x-www-form-urlencoded`. A client POSTing JSON or
1147 // multipart used to have its body parse to an empty map and then
1148 // get a wall of "field required" errors — mis-diagnosing a wrong
1149 // Content-Type as missing fields. Reject a present-but-wrong
1150 // Content-Type up front with 415, like axum's own `Form`.
1151 let content_type = req
1152 .headers()
1153 .get(axum::http::header::CONTENT_TYPE)
1154 .and_then(|v| v.to_str().ok())
1155 .map(|s| s.to_ascii_lowercase());
1156 if let Some(ct) = &content_type
1157 && !ct.starts_with("application/x-www-form-urlencoded")
1158 {
1159 return Err((
1160 StatusCode::UNSUPPORTED_MEDIA_TYPE,
1161 "this endpoint expects application/x-www-form-urlencoded form data",
1162 )
1163 .into_response());
1164 }
1165
1166 // Read the body up to the CONFIGURED limit. Defaults to 16 MiB
1167 // (`Settings::max_form_body_bytes`); set `UMBRAL_MAX_FORM_BODY_BYTES`, or
1168 // `0` to disable the cap entirely. Buffering an unbounded urlencoded
1169 // body is a DoS risk, so a cap is the default — but it's no longer
1170 // hardcoded, and `0` removes it for dev / large forms.
1171 const FALLBACK_MAX_FORM_BODY: usize = 16 * 1024 * 1024;
1172 let max_body = match crate::settings::get_opt() {
1173 Some(s) => match s.max_form_body_bytes {
1174 None | Some(0) => usize::MAX, // explicitly disabled = no cap
1175 Some(n) => n,
1176 },
1177 None => FALLBACK_MAX_FORM_BODY, // Settings not published (low-level tests)
1178 };
1179 let bytes = match to_bytes(req.into_body(), max_body).await {
1180 Ok(b) => b,
1181 Err(_) => {
1182 return Err((
1183 StatusCode::PAYLOAD_TOO_LARGE,
1184 "form body exceeds the configured limit (Settings::max_form_body_bytes)",
1185 )
1186 .into_response());
1187 }
1188 };
1189
1190 // Parse x-www-form-urlencoded into a String->String map. An empty
1191 // body parses to an empty map (Ok) — `FormValidate::validate` then
1192 // sees every field as missing and surfaces the right per-field
1193 // "required" errors. BROKEN-8: a genuinely MALFORMED body must not
1194 // be swallowed into an empty map (that re-runs as bogus "field
1195 // required" errors); surface it as a 400 naming the parse failure.
1196 let pairs: std::collections::HashMap<String, String> =
1197 match serde_urlencoded::from_bytes(&bytes) {
1198 Ok(pairs) => pairs,
1199 Err(e) => {
1200 return Err((
1201 StatusCode::BAD_REQUEST,
1202 format!("malformed urlencoded form body: {e}"),
1203 )
1204 .into_response());
1205 }
1206 };
1207
1208 // Run validation. On success, we've already proven the data
1209 // fits T's shape — return Ok(T). On failure, lift the
1210 // ValidationErrors to a FormErrors AND attach the raw
1211 // pairs so the handler can render the template pre-filled
1212 // with what the user typed. Without this the user loses
1213 // every keystroke on validation failure — see gaps2 #19
1214 // follow-up commit for the bug screenshot that prompted
1215 // this change.
1216 match T::validate(&pairs).await {
1217 Ok(value) => Ok(Self::valid(value)),
1218 Err(errs) => {
1219 let write_err: WriteError = errs.into();
1220 Ok(Self::invalid(FormErrors::with_raw(write_err, pairs)))
1221 }
1222 }
1223 }
1224}
1225
1226// =========================================================================
1227// Tests live inline because the surface is pure (no DB, no async).
1228// =========================================================================
1229
1230#[cfg(test)]
1231mod tests {
1232 use super::*;
1233
1234 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1235 pairs
1236 .iter()
1237 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1238 .collect()
1239 }
1240
1241 #[test]
1242 fn required_field_rejects_empty_value() {
1243 let f = Field::text("username");
1244 let mut errs = ValidationErrors::new();
1245 let form = data(&[("username", "")]);
1246 f.validate(form.get("username").unwrap(), &mut errs);
1247 assert!(errs.fields.contains_key("username"));
1248 assert!(errs.fields["username"][0].contains("required"));
1249 }
1250
1251 #[test]
1252 fn optional_field_with_empty_value_passes() {
1253 let f = Field::text("bio").optional();
1254 let mut errs = ValidationErrors::new();
1255 f.validate("", &mut errs);
1256 assert!(errs.is_empty());
1257 }
1258
1259 #[test]
1260 fn min_max_length_combine_on_one_field() {
1261 let f = Field::text("title").min_length(3).max_length(5);
1262 let mut errs = ValidationErrors::new();
1263 f.validate("ab", &mut errs);
1264 assert!(errs.fields["title"][0].contains("at least 3"));
1265
1266 let mut errs = ValidationErrors::new();
1267 f.validate("toolong", &mut errs);
1268 assert!(errs.fields["title"][0].contains("at most 5"));
1269
1270 let mut errs = ValidationErrors::new();
1271 f.validate("abcd", &mut errs);
1272 assert!(errs.is_empty());
1273 }
1274
1275 #[test]
1276 fn integer_field_rejects_non_numeric_input() {
1277 let f = Field::integer("age");
1278 let mut errs = ValidationErrors::new();
1279 f.validate("twelve", &mut errs);
1280 assert!(errs.fields["age"][0].contains("whole number"));
1281
1282 let mut errs = ValidationErrors::new();
1283 f.validate("42", &mut errs);
1284 assert!(errs.is_empty());
1285 }
1286
1287 #[test]
1288 fn email_field_runs_the_built_in_format_check() {
1289 let f = Field::email("email");
1290
1291 let mut errs = ValidationErrors::new();
1292 f.validate("not-an-email", &mut errs);
1293 assert!(!errs.is_empty());
1294
1295 let mut errs = ValidationErrors::new();
1296 f.validate("alice@example.com", &mut errs);
1297 assert!(errs.is_empty());
1298
1299 // Local part missing
1300 let mut errs = ValidationErrors::new();
1301 f.validate("@example.com", &mut errs);
1302 assert!(!errs.is_empty());
1303
1304 // Domain missing a dot
1305 let mut errs = ValidationErrors::new();
1306 f.validate("alice@example", &mut errs);
1307 assert!(!errs.is_empty());
1308 }
1309
1310 #[test]
1311 fn non_field_errors_propagate_through_into_result() {
1312 let mut errs = ValidationErrors::new();
1313 errs.add_non_field("passwords do not match");
1314 let result = errs.into_result();
1315 match result {
1316 Err(e) => {
1317 assert_eq!(e.non_field.len(), 1);
1318 assert!(e.non_field[0].contains("passwords"));
1319 }
1320 Ok(_) => panic!("non-field error should fail into_result"),
1321 }
1322 }
1323
1324 #[test]
1325 fn render_html_escapes_user_input_against_xss() {
1326 let f = Field::text("title");
1327 let rendered = f.render_html("<script>alert(1)</script>");
1328 assert!(rendered.contains("<script>"));
1329 assert!(!rendered.contains("<script>alert"));
1330 assert!(rendered.contains("name=\"title\""));
1331 assert!(rendered.contains("required"));
1332 }
1333
1334 #[test]
1335 fn render_html_emits_the_right_input_type_per_field_kind() {
1336 assert!(Field::text("a").render_html("").contains("type=\"text\""));
1337 assert!(Field::email("a").render_html("").contains("type=\"email\""));
1338 assert!(
1339 Field::password("a")
1340 .render_html("")
1341 .contains("type=\"password\"")
1342 );
1343 assert!(
1344 Field::integer("a")
1345 .render_html("")
1346 .contains("type=\"number\"")
1347 );
1348 assert!(
1349 Field::boolean("a")
1350 .render_html("")
1351 .contains("type=\"checkbox\"")
1352 );
1353 }
1354
1355 #[test]
1356 fn boolean_field_renders_checked_when_value_is_truthy() {
1357 let f = Field::boolean("is_admin");
1358 assert!(f.render_html("true").contains(" checked"));
1359 assert!(f.render_html("on").contains(" checked"));
1360 assert!(f.render_html("1").contains(" checked"));
1361 assert!(!f.render_html("").contains(" checked"));
1362 assert!(!f.render_html("false").contains(" checked"));
1363 }
1364
1365 /// Demo composition: a tiny LoginForm built from primitive
1366 /// fields. Stands in for what a `#[derive(Form)]` would produce.
1367 /// Validates a HashMap, returns a typed struct, accumulates
1368 /// every field's errors.
1369 #[derive(Debug, PartialEq, Eq)]
1370 struct LoginForm {
1371 username: String,
1372 password: String,
1373 }
1374
1375 impl LoginForm {
1376 fn validate(form: &HashMap<String, String>) -> Result<Self, ValidationErrors> {
1377 let username_field = Field::text("username").min_length(3).max_length(150);
1378 let password_field = Field::password("password").min_length(8);
1379 let mut errs = ValidationErrors::new();
1380 let username = form.get("username").cloned().unwrap_or_default();
1381 let password = form.get("password").cloned().unwrap_or_default();
1382 username_field.validate(&username, &mut errs);
1383 password_field.validate(&password, &mut errs);
1384 errs.into_result()?;
1385 Ok(Self { username, password })
1386 }
1387 }
1388
1389 #[test]
1390 fn login_form_demo_validates_happy_path() {
1391 let input = data(&[("username", "alice"), ("password", "hunter2-stronger")]);
1392 let form = LoginForm::validate(&input).expect("happy path");
1393 assert_eq!(form.username, "alice");
1394 assert_eq!(form.password, "hunter2-stronger");
1395 }
1396
1397 #[test]
1398 fn login_form_demo_collects_every_field_error_at_once() {
1399 let input = data(&[("username", "ab"), ("password", "short")]);
1400 let err = LoginForm::validate(&input).expect_err("both fields fail");
1401 assert!(err.fields.contains_key("username"));
1402 assert!(err.fields.contains_key("password"));
1403 assert!(err.fields["username"][0].contains("at least 3"));
1404 assert!(err.fields["password"][0].contains("at least 8"));
1405 }
1406
1407 // =====================================================================
1408 // gaps2 #19 follow-up — FormErrors carries the raw form pairs so the
1409 // handler can re-render the template pre-filled with what the user
1410 // typed instead of falling back to `T::default()` (which loses every
1411 // keystroke). Screenshot 2026-06-10 01-03-09 reported the data-loss
1412 // bug pre-fix.
1413 // =====================================================================
1414
1415 // =====================================================================
1416 // gaps2 #19 follow-up — regex / phone / url validators
1417 // =====================================================================
1418
1419 #[test]
1420 fn phone_field_accepts_e164_format() {
1421 let f = Field::phone("phone");
1422 let mut errs = ValidationErrors::new();
1423 f.validate("+14155551234", &mut errs);
1424 assert!(errs.is_empty(), "valid E.164 should pass: {:?}", errs);
1425 }
1426
1427 #[test]
1428 fn phone_field_rejects_local_only_format() {
1429 // The bug report case: "07065" got accepted because the
1430 // field had only `optional + max_length`. With `Field::phone`
1431 // (== `#[form(phone)]`) the E.164 regex rejects it.
1432 let f = Field::phone("phone");
1433 let mut errs = ValidationErrors::new();
1434 f.validate("07065", &mut errs);
1435 assert!(errs.fields.contains_key("phone"));
1436 assert!(
1437 errs.fields["phone"][0].contains("E.164"),
1438 "error message names the format: {:?}",
1439 errs.fields["phone"][0]
1440 );
1441 }
1442
1443 #[test]
1444 fn phone_field_rejects_letters_and_punctuation() {
1445 let f = Field::phone("phone");
1446 for bad in &["+1-415-555-1234", "+1 (415) 555 1234", "+1abc", "+0123"] {
1447 let mut errs = ValidationErrors::new();
1448 f.validate(bad, &mut errs);
1449 assert!(
1450 errs.fields.contains_key("phone"),
1451 "should reject `{bad}`: {:?}",
1452 errs.fields
1453 );
1454 }
1455 }
1456
1457 #[test]
1458 fn url_field_accepts_http_and_https_only() {
1459 let f = Field::url("homepage");
1460 for good in &["https://example.com", "http://example.com/path?q=1"] {
1461 let mut errs = ValidationErrors::new();
1462 f.validate(good, &mut errs);
1463 assert!(errs.is_empty(), "should accept `{good}`: {:?}", errs);
1464 }
1465 for bad in &["ftp://example.com", "mailto:a@b.c", "example.com"] {
1466 let mut errs = ValidationErrors::new();
1467 f.validate(bad, &mut errs);
1468 assert!(
1469 errs.fields.contains_key("homepage"),
1470 "should reject `{bad}`: {:?}",
1471 errs.fields
1472 );
1473 }
1474 }
1475
1476 #[test]
1477 fn regex_validator_substitutes_field_in_message() {
1478 // {field} placeholder gets the actual field name — useful
1479 // for reusable messages across multiple forms.
1480 let f = Field::text("invoice_id")
1481 .regex(r"^INV-\d{6}$", "{field} must match the invoice pattern");
1482 let mut errs = ValidationErrors::new();
1483 f.validate("not-an-invoice", &mut errs);
1484 assert_eq!(
1485 errs.fields["invoice_id"][0],
1486 "invoice_id must match the invoice pattern"
1487 );
1488 }
1489
1490 #[test]
1491 fn regex_validator_composes_with_required_and_max_length() {
1492 // Order: Required runs FIRST (empty → "is required"),
1493 // then max_length, then regex. An empty value should
1494 // surface the "required" error, not "doesn't match pattern".
1495 let f = Field::text("code")
1496 .max_length(8)
1497 .regex(r"^[A-Z]{3}$", "{field} must be 3 uppercase letters");
1498
1499 let mut errs = ValidationErrors::new();
1500 f.validate("", &mut errs);
1501 assert!(
1502 errs.fields["code"][0].contains("required"),
1503 "empty surfaces required error first: {:?}",
1504 errs.fields["code"][0]
1505 );
1506
1507 let mut errs = ValidationErrors::new();
1508 f.validate("HELLO", &mut errs);
1509 assert!(
1510 errs.fields["code"][0].contains("3 uppercase"),
1511 "regex error fires when value is present but malformed: {:?}",
1512 errs.fields["code"][0]
1513 );
1514 }
1515
1516 #[test]
1517 fn form_errors_with_raw_round_trips_the_submitted_pairs() {
1518 let mut raw = HashMap::new();
1519 raw.insert("name".to_string(), "Bella Verifier".to_string());
1520 raw.insert("email".to_string(), "bella@invalid".to_string());
1521 raw.insert("phone".to_string(), "none".to_string());
1522
1523 let errs = FormErrors::with_raw(
1524 WriteError::Validator {
1525 field: "email".to_string(),
1526 message: "email's domain must contain at least one `.`".to_string(),
1527 },
1528 raw.clone(),
1529 );
1530
1531 // Raw values survive untouched.
1532 assert_eq!(
1533 errs.raw_values().get("name").map(|s| s.as_str()),
1534 Some("Bella Verifier"),
1535 );
1536 assert_eq!(
1537 errs.raw_values().get("phone").map(|s| s.as_str()),
1538 Some("none"),
1539 );
1540
1541 // JSON shape is a flat `{ field: "literal user input" }` map,
1542 // ready to drop straight into a template ctx as `form` so
1543 // `{{ form.name }}` repopulates.
1544 let json = errs.raw_as_json();
1545 let obj = json.as_object().expect("raw_as_json is an object");
1546 assert_eq!(
1547 obj.get("name").and_then(|v| v.as_str()),
1548 Some("Bella Verifier")
1549 );
1550 assert_eq!(obj.get("phone").and_then(|v| v.as_str()), Some("none"));
1551 }
1552
1553 #[test]
1554 fn form_errors_new_defaults_raw_to_empty_for_ad_hoc_construction() {
1555 // FormErrors::new doesn't see the request body — common shape
1556 // for handlers that construct an ad-hoc error after the
1557 // extractor ran. Raw map MUST default to empty, not panic.
1558 let errs = FormErrors::new(WriteError::Validator {
1559 field: "form".to_string(),
1560 message: "rate limited".to_string(),
1561 });
1562 assert!(errs.raw_values().is_empty());
1563 // JSON shape stays a valid empty object — template ctx
1564 // doesn't crash when nothing was submitted.
1565 let json = errs.raw_as_json();
1566 assert!(json.as_object().expect("object").is_empty());
1567 }
1568}