use std::collections::HashMap;
use async_trait::async_trait;
#[doc(hidden)]
pub use async_trait::async_trait as async_trait_reexport;
#[async_trait]
pub trait FormValidate: Sized {
async fn validate(data: &HashMap<String, String>) -> Result<Self, ValidationErrors>;
fn fields() -> Vec<Field>;
async fn render_html(data: &HashMap<String, String>) -> String {
let mut out = String::new();
for field in Self::fields() {
let value = data.get(&field.name).map(String::as_str).unwrap_or("");
out.push_str("<div class=\"field\">");
out.push_str(&format!(
"<label for=\"{name}\">{name}</label>",
name = field.name
));
out.push_str(&field.render_html_async(value).await);
out.push_str("</div>");
}
out
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ValidationErrors {
pub fields: HashMap<String, Vec<String>>,
pub non_field: Vec<String>,
}
impl ValidationErrors {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, field: &str, message: impl Into<String>) {
self.fields
.entry(field.to_string())
.or_default()
.push(message.into());
}
pub fn add_non_field(&mut self, message: impl Into<String>) {
self.non_field.push(message.into());
}
pub fn is_empty(&self) -> bool {
self.fields.is_empty() && self.non_field.is_empty()
}
pub fn into_result(self) -> Result<(), Self> {
if self.is_empty() { Ok(()) } else { Err(self) }
}
}
impl std::fmt::Display for ValidationErrors {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for msg in &self.non_field {
writeln!(f, "form: {msg}")?;
}
for (field, msgs) in &self.fields {
for msg in msgs {
writeln!(f, "{field}: {msg}")?;
}
}
Ok(())
}
}
impl std::error::Error for ValidationErrors {}
pub trait Validator: Send + Sync {
fn check(&self, field_name: &str, value: &str) -> Result<(), String>;
}
pub struct Required;
impl Validator for Required {
fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
if value.trim().is_empty() {
Err(format!("{field_name} is required"))
} else {
Ok(())
}
}
}
pub struct MinLength(pub usize);
impl Validator for MinLength {
fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
if value.chars().count() < self.0 {
Err(format!(
"{field_name} must be at least {} characters",
self.0
))
} else {
Ok(())
}
}
}
pub struct MaxLength(pub usize);
impl Validator for MaxLength {
fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
if value.chars().count() > self.0 {
Err(format!(
"{field_name} must be at most {} characters",
self.0
))
} else {
Ok(())
}
}
}
pub struct EmailFormat;
impl Validator for EmailFormat {
fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
let Some((local, domain)) = value.split_once('@') else {
return Err(format!("{field_name} must contain `@`"));
};
if local.is_empty() {
return Err(format!("{field_name} is missing a local part before `@`"));
}
if !domain.contains('.') {
return Err(format!(
"{field_name}'s domain must contain at least one `.`"
));
}
if domain.starts_with('.') || domain.ends_with('.') {
return Err(format!("{field_name}'s domain is malformed"));
}
Ok(())
}
}
pub struct RegexFormat {
pattern: regex::Regex,
message: String,
}
impl RegexFormat {
pub fn new(pattern: &str, message: impl Into<String>) -> Self {
Self {
pattern: regex::Regex::new(pattern)
.unwrap_or_else(|e| panic!("RegexFormat: invalid pattern `{pattern}`: {e}")),
message: message.into(),
}
}
}
impl Validator for RegexFormat {
fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
if self.pattern.is_match(value) {
Ok(())
} else {
Err(self.message.replace("{field}", field_name))
}
}
}
pub const PHONE_E164_PATTERN: &str = r"^\+[1-9]\d{1,14}$";
pub const URL_PATTERN: &str = r"^https?://[A-Za-z0-9._~:%/?#\[\]@!$&'()*+,;=-]+$";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PkKind {
BigInt,
Uuid,
Text,
}
#[derive(Debug, Clone, Copy)]
pub enum InputKind {
Text,
Number,
Email,
Tel,
Url,
Password,
Checkbox,
Date,
Time,
DatetimeLocal,
Textarea,
File,
Select,
ModelChoice {
target_table: &'static str,
label_field: Option<&'static str>,
pk_kind: PkKind,
},
ModelMultiChoice {
target_table: &'static str,
label_field: Option<&'static str>,
pk_kind: PkKind,
},
}
impl InputKind {
fn html_type(self) -> &'static str {
match self {
InputKind::Text | InputKind::Textarea => "text",
InputKind::Number => "number",
InputKind::Email => "email",
InputKind::Tel => "tel",
InputKind::Url => "url",
InputKind::Password => "password",
InputKind::Checkbox => "checkbox",
InputKind::Date => "date",
InputKind::Time => "time",
InputKind::DatetimeLocal => "datetime-local",
InputKind::File => "file",
InputKind::Select
| InputKind::ModelChoice { .. }
| InputKind::ModelMultiChoice { .. } => "text",
}
}
}
pub struct Field {
pub name: String,
pub kind: InputKind,
pub required: bool,
pub validators: Vec<Box<dyn Validator>>,
pub options: Vec<(String, String)>,
}
impl Field {
pub fn text(name: impl Into<String>) -> Self {
Self {
name: name.into(),
kind: InputKind::Text,
required: true,
validators: vec![Box::new(Required)],
options: Vec::new(),
}
}
pub fn email(name: impl Into<String>) -> Self {
let mut f = Self::text(name);
f.kind = InputKind::Email;
f.validators.push(Box::new(EmailFormat));
f
}
pub fn regex(mut self, pattern: &str, message: impl Into<String>) -> Self {
self.validators
.push(Box::new(RegexFormat::new(pattern, message)));
self
}
pub fn phone(name: impl Into<String>) -> Self {
let mut f = Self::text(name);
f.kind = InputKind::Tel;
f.validators.push(Box::new(RegexFormat::new(
PHONE_E164_PATTERN,
"{field} must be E.164 format — `+` then country code then number, no spaces",
)));
f
}
pub fn url(name: impl Into<String>) -> Self {
let mut f = Self::text(name);
f.kind = InputKind::Url;
f.validators.push(Box::new(RegexFormat::new(
URL_PATTERN,
"{field} must be an http(s):// URL",
)));
f
}
pub fn password(name: impl Into<String>) -> Self {
let mut f = Self::text(name);
f.kind = InputKind::Password;
f
}
pub fn file(name: impl Into<String>) -> Self {
Self {
name: name.into(),
kind: InputKind::File,
required: true,
validators: vec![Box::new(Required)],
options: Vec::new(),
}
}
pub fn integer(name: impl Into<String>) -> Self {
Self {
name: name.into(),
kind: InputKind::Number,
required: true,
validators: vec![Box::new(Required), Box::new(IntegerFormat)],
options: Vec::new(),
}
}
pub fn float(name: impl Into<String>) -> Self {
Self {
name: name.into(),
kind: InputKind::Number,
required: true,
validators: vec![Box::new(Required), Box::new(FloatFormat)],
options: Vec::new(),
}
}
pub fn boolean(name: impl Into<String>) -> Self {
Self {
name: name.into(),
kind: InputKind::Checkbox,
required: false,
validators: Vec::new(),
options: Vec::new(),
}
}
pub fn select(name: impl Into<String>, options: Vec<(String, String)>, nullable: bool) -> Self {
let mut opts = options;
if nullable {
opts.insert(0, (String::new(), String::new()));
}
Self {
name: name.into(),
kind: InputKind::Select,
required: !nullable,
validators: Vec::new(),
options: opts,
}
}
pub fn model_choice(
name: impl Into<String>,
target_table: &'static str,
label_field: Option<&'static str>,
pk_kind: PkKind,
nullable: bool,
) -> Self {
Self {
name: name.into(),
kind: InputKind::ModelChoice {
target_table,
label_field,
pk_kind,
},
required: !nullable,
validators: Vec::new(),
options: Vec::new(),
}
}
pub fn model_multi_choice(
name: impl Into<String>,
target_table: &'static str,
label_field: Option<&'static str>,
pk_kind: PkKind,
) -> Self {
Self {
name: name.into(),
kind: InputKind::ModelMultiChoice {
target_table,
label_field,
pk_kind,
},
required: false,
validators: Vec::new(),
options: Vec::new(),
}
}
pub fn optional(mut self) -> Self {
self.required = false;
self
}
pub fn min_length(mut self, n: usize) -> Self {
self.validators.push(Box::new(MinLength(n)));
self
}
pub fn max_length(mut self, n: usize) -> Self {
self.validators.push(Box::new(MaxLength(n)));
self
}
pub fn with_validator(mut self, v: impl Validator + 'static) -> Self {
self.validators.push(Box::new(v));
self
}
pub fn validate(&self, value: &str, errors: &mut ValidationErrors) {
if !self.required && value.is_empty() {
return;
}
for v in &self.validators {
if let Err(msg) = v.check(&self.name, value) {
errors.add(&self.name, msg);
}
}
}
pub fn render_html(&self, value: &str) -> String {
let safe_value = html_escape(value);
let required = if self.required { " required" } else { "" };
match self.kind {
InputKind::Textarea => format!(
"<textarea name=\"{name}\"{required}>{safe_value}</textarea>",
name = self.name,
),
InputKind::Checkbox => {
let checked = if value == "true" || value == "on" || value == "1" {
" checked"
} else {
""
};
format!(
"<input type=\"checkbox\" name=\"{name}\" value=\"true\"{checked}>",
name = self.name,
)
}
InputKind::Select => {
let mut s = format!(
"<select name=\"{name}\"{required}>",
name = self.name,
required = required
);
for (val, label) in &self.options {
let selected = if val == value { " selected" } else { "" };
s.push_str(&format!(
"<option value=\"{v}\"{selected}>{l}</option>",
v = html_escape(val),
l = html_escape(label),
));
}
s.push_str("</select>");
s
}
InputKind::File => format!(
"<input type=\"file\" name=\"{name}\"{required}>",
name = self.name,
),
other => format!(
"<input type=\"{ty}\" name=\"{name}\" value=\"{safe_value}\"{required}>",
ty = other.html_type(),
name = self.name,
),
}
}
pub async fn render_html_async(&self, value: &str) -> String {
match self.kind {
InputKind::ModelChoice {
target_table,
label_field,
..
} => {
let options =
crate::orm::forms_runtime::fetch_model_options(target_table, label_field).await;
self.render_select(&options, value, false)
}
InputKind::ModelMultiChoice {
target_table,
label_field,
..
} => {
let options =
crate::orm::forms_runtime::fetch_model_options(target_table, label_field).await;
self.render_select(&options, value, true)
}
_ => self.render_html(value),
}
}
fn render_select(&self, options: &[(String, String)], value: &str, multiple: bool) -> String {
let multiple_attr = if multiple { " multiple" } else { "" };
let required = if self.required { " required" } else { "" };
let mut s = format!(
"<select name=\"{name}\"{multiple_attr}{required}>",
name = self.name,
);
if !multiple && !self.required {
s.push_str("<option value=\"\"></option>");
}
for (val, label) in options {
let selected = if val == value { " selected" } else { "" };
s.push_str(&format!(
"<option value=\"{v}\"{selected}>{l}</option>",
v = html_escape(val),
l = html_escape(label),
));
}
s.push_str("</select>");
s
}
}
struct IntegerFormat;
impl Validator for IntegerFormat {
fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
value
.parse::<i64>()
.map(|_| ())
.map_err(|_| format!("{field_name} must be a whole number"))
}
}
struct FloatFormat;
impl Validator for FloatFormat {
fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
value
.parse::<f64>()
.map(|_| ())
.map_err(|_| format!("{field_name} must be a number"))
}
}
fn html_escape(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
use crate::orm::write::WriteError;
#[derive(Debug)]
pub struct FormErrors {
inner: WriteError,
raw: HashMap<String, String>,
}
impl FormErrors {
pub fn new(err: WriteError) -> Self {
Self {
inner: err,
raw: HashMap::new(),
}
}
pub fn with_raw(err: WriteError, raw: HashMap<String, String>) -> Self {
Self { inner: err, raw }
}
pub fn raw_values(&self) -> &HashMap<String, String> {
&self.raw
}
pub fn raw_as_json(&self) -> serde_json::Value {
serde_json::Value::Object(
self.raw
.iter()
.map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
.collect(),
)
}
pub fn as_write_error(&self) -> &WriteError {
&self.inner
}
pub fn into_write_error(self) -> WriteError {
self.inner
}
pub fn field_errors(&self) -> std::collections::BTreeMap<String, Vec<String>> {
self.inner.field_errors()
}
pub fn non_field_errors(&self) -> Vec<String> {
self.inner.non_field_errors()
}
pub fn as_template_ctx(&self) -> serde_json::Map<String, serde_json::Value> {
let mut out = serde_json::Map::new();
for (key, msgs) in self.field_errors() {
if let Some(first) = msgs.into_iter().next() {
out.insert(key, serde_json::Value::String(first));
}
}
if let Some(first) = self.non_field_errors().into_iter().next() {
out.insert("form".to_string(), serde_json::Value::String(first));
}
out
}
pub fn render(&self, template: &str) -> axum::response::Response {
self.render_with(template, serde_json::Map::new())
}
pub fn render_with(
&self,
template: &str,
extra: serde_json::Map<String, serde_json::Value>,
) -> axum::response::Response {
use axum::response::IntoResponse;
let mut errors = self.as_template_ctx();
errors.entry("form".to_string()).or_insert_with(|| {
serde_json::Value::String(
"Please fix the highlighted fields and try again.".to_string(),
)
});
let mut ctx = serde_json::Map::new();
ctx.insert("form".to_string(), self.raw_as_json());
ctx.insert("errors".to_string(), serde_json::Value::Object(errors));
for (k, v) in extra {
ctx.insert(k, v);
}
match crate::templates::render(template, &serde_json::Value::Object(ctx)) {
Ok(html) => (
axum::http::StatusCode::UNPROCESSABLE_ENTITY,
axum::response::Html(html),
)
.into_response(),
Err(e) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("form re-render failed for `{template}`: {e}"),
)
.into_response(),
}
}
}
impl std::fmt::Display for FormErrors {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.inner)
}
}
impl std::error::Error for FormErrors {}
impl From<WriteError> for FormErrors {
fn from(e: WriteError) -> Self {
Self::new(e)
}
}
impl From<ValidationErrors> for WriteError {
fn from(e: ValidationErrors) -> Self {
let mut out: Vec<WriteError> = Vec::new();
for (field, msgs) in e.fields {
for message in msgs {
out.push(WriteError::Validator {
field: field.clone(),
message,
});
}
}
for message in e.non_field {
out.push(WriteError::Validator {
field: String::new(),
message,
});
}
if out.len() == 1 {
out.into_iter().next().expect("len == 1")
} else {
WriteError::Multiple { errors: out }
}
}
}
impl From<ValidationErrors> for FormErrors {
fn from(e: ValidationErrors) -> Self {
Self::new(e.into())
}
}
pub struct Form<T> {
inner: Result<T, FormErrors>,
}
impl<T> Form<T> {
pub fn valid(value: T) -> Self {
Self { inner: Ok(value) }
}
pub fn invalid(errors: FormErrors) -> Self {
Self { inner: Err(errors) }
}
pub fn into_result(self) -> Result<T, FormErrors> {
self.inner
}
pub fn as_result(&self) -> Result<&T, &FormErrors> {
self.inner.as_ref()
}
}
impl<T, S> axum::extract::FromRequest<S> for Form<T>
where
T: FormValidate + serde::de::DeserializeOwned + Send + 'static,
S: Send + Sync,
{
type Rejection = axum::response::Response;
async fn from_request(
req: axum::extract::Request,
_state: &S,
) -> Result<Self, Self::Rejection> {
use axum::body::to_bytes;
use axum::http::StatusCode;
use axum::response::IntoResponse;
let content_type = req
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_ascii_lowercase());
if let Some(ct) = &content_type
&& !ct.starts_with("application/x-www-form-urlencoded")
{
return Err((
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"this endpoint expects application/x-www-form-urlencoded form data",
)
.into_response());
}
const FALLBACK_MAX_FORM_BODY: usize = 16 * 1024 * 1024;
let max_body = match crate::settings::get_opt() {
Some(s) => match s.max_form_body_bytes {
None | Some(0) => usize::MAX, Some(n) => n,
},
None => FALLBACK_MAX_FORM_BODY, };
let bytes = match to_bytes(req.into_body(), max_body).await {
Ok(b) => b,
Err(_) => {
return Err((
StatusCode::PAYLOAD_TOO_LARGE,
"form body exceeds the configured limit (Settings::max_form_body_bytes)",
)
.into_response());
}
};
let pairs: std::collections::HashMap<String, String> =
match serde_urlencoded::from_bytes(&bytes) {
Ok(pairs) => pairs,
Err(e) => {
return Err((
StatusCode::BAD_REQUEST,
format!("malformed urlencoded form body: {e}"),
)
.into_response());
}
};
match T::validate(&pairs).await {
Ok(value) => Ok(Self::valid(value)),
Err(errs) => {
let write_err: WriteError = errs.into();
Ok(Self::invalid(FormErrors::with_raw(write_err, pairs)))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
#[test]
fn required_field_rejects_empty_value() {
let f = Field::text("username");
let mut errs = ValidationErrors::new();
let form = data(&[("username", "")]);
f.validate(form.get("username").unwrap(), &mut errs);
assert!(errs.fields.contains_key("username"));
assert!(errs.fields["username"][0].contains("required"));
}
#[test]
fn optional_field_with_empty_value_passes() {
let f = Field::text("bio").optional();
let mut errs = ValidationErrors::new();
f.validate("", &mut errs);
assert!(errs.is_empty());
}
#[test]
fn min_max_length_combine_on_one_field() {
let f = Field::text("title").min_length(3).max_length(5);
let mut errs = ValidationErrors::new();
f.validate("ab", &mut errs);
assert!(errs.fields["title"][0].contains("at least 3"));
let mut errs = ValidationErrors::new();
f.validate("toolong", &mut errs);
assert!(errs.fields["title"][0].contains("at most 5"));
let mut errs = ValidationErrors::new();
f.validate("abcd", &mut errs);
assert!(errs.is_empty());
}
#[test]
fn integer_field_rejects_non_numeric_input() {
let f = Field::integer("age");
let mut errs = ValidationErrors::new();
f.validate("twelve", &mut errs);
assert!(errs.fields["age"][0].contains("whole number"));
let mut errs = ValidationErrors::new();
f.validate("42", &mut errs);
assert!(errs.is_empty());
}
#[test]
fn email_field_runs_the_built_in_format_check() {
let f = Field::email("email");
let mut errs = ValidationErrors::new();
f.validate("not-an-email", &mut errs);
assert!(!errs.is_empty());
let mut errs = ValidationErrors::new();
f.validate("alice@example.com", &mut errs);
assert!(errs.is_empty());
let mut errs = ValidationErrors::new();
f.validate("@example.com", &mut errs);
assert!(!errs.is_empty());
let mut errs = ValidationErrors::new();
f.validate("alice@example", &mut errs);
assert!(!errs.is_empty());
}
#[test]
fn non_field_errors_propagate_through_into_result() {
let mut errs = ValidationErrors::new();
errs.add_non_field("passwords do not match");
let result = errs.into_result();
match result {
Err(e) => {
assert_eq!(e.non_field.len(), 1);
assert!(e.non_field[0].contains("passwords"));
}
Ok(_) => panic!("non-field error should fail into_result"),
}
}
#[test]
fn render_html_escapes_user_input_against_xss() {
let f = Field::text("title");
let rendered = f.render_html("<script>alert(1)</script>");
assert!(rendered.contains("<script>"));
assert!(!rendered.contains("<script>alert"));
assert!(rendered.contains("name=\"title\""));
assert!(rendered.contains("required"));
}
#[test]
fn render_html_emits_the_right_input_type_per_field_kind() {
assert!(Field::text("a").render_html("").contains("type=\"text\""));
assert!(Field::email("a").render_html("").contains("type=\"email\""));
assert!(
Field::password("a")
.render_html("")
.contains("type=\"password\"")
);
assert!(
Field::integer("a")
.render_html("")
.contains("type=\"number\"")
);
assert!(
Field::boolean("a")
.render_html("")
.contains("type=\"checkbox\"")
);
}
#[test]
fn boolean_field_renders_checked_when_value_is_truthy() {
let f = Field::boolean("is_admin");
assert!(f.render_html("true").contains(" checked"));
assert!(f.render_html("on").contains(" checked"));
assert!(f.render_html("1").contains(" checked"));
assert!(!f.render_html("").contains(" checked"));
assert!(!f.render_html("false").contains(" checked"));
}
#[derive(Debug, PartialEq, Eq)]
struct LoginForm {
username: String,
password: String,
}
impl LoginForm {
fn validate(form: &HashMap<String, String>) -> Result<Self, ValidationErrors> {
let username_field = Field::text("username").min_length(3).max_length(150);
let password_field = Field::password("password").min_length(8);
let mut errs = ValidationErrors::new();
let username = form.get("username").cloned().unwrap_or_default();
let password = form.get("password").cloned().unwrap_or_default();
username_field.validate(&username, &mut errs);
password_field.validate(&password, &mut errs);
errs.into_result()?;
Ok(Self { username, password })
}
}
#[test]
fn login_form_demo_validates_happy_path() {
let input = data(&[("username", "alice"), ("password", "hunter2-stronger")]);
let form = LoginForm::validate(&input).expect("happy path");
assert_eq!(form.username, "alice");
assert_eq!(form.password, "hunter2-stronger");
}
#[test]
fn login_form_demo_collects_every_field_error_at_once() {
let input = data(&[("username", "ab"), ("password", "short")]);
let err = LoginForm::validate(&input).expect_err("both fields fail");
assert!(err.fields.contains_key("username"));
assert!(err.fields.contains_key("password"));
assert!(err.fields["username"][0].contains("at least 3"));
assert!(err.fields["password"][0].contains("at least 8"));
}
#[test]
fn phone_field_accepts_e164_format() {
let f = Field::phone("phone");
let mut errs = ValidationErrors::new();
f.validate("+14155551234", &mut errs);
assert!(errs.is_empty(), "valid E.164 should pass: {:?}", errs);
}
#[test]
fn phone_field_rejects_local_only_format() {
let f = Field::phone("phone");
let mut errs = ValidationErrors::new();
f.validate("07065", &mut errs);
assert!(errs.fields.contains_key("phone"));
assert!(
errs.fields["phone"][0].contains("E.164"),
"error message names the format: {:?}",
errs.fields["phone"][0]
);
}
#[test]
fn phone_field_rejects_letters_and_punctuation() {
let f = Field::phone("phone");
for bad in &["+1-415-555-1234", "+1 (415) 555 1234", "+1abc", "+0123"] {
let mut errs = ValidationErrors::new();
f.validate(bad, &mut errs);
assert!(
errs.fields.contains_key("phone"),
"should reject `{bad}`: {:?}",
errs.fields
);
}
}
#[test]
fn url_field_accepts_http_and_https_only() {
let f = Field::url("homepage");
for good in &["https://example.com", "http://example.com/path?q=1"] {
let mut errs = ValidationErrors::new();
f.validate(good, &mut errs);
assert!(errs.is_empty(), "should accept `{good}`: {:?}", errs);
}
for bad in &["ftp://example.com", "mailto:a@b.c", "example.com"] {
let mut errs = ValidationErrors::new();
f.validate(bad, &mut errs);
assert!(
errs.fields.contains_key("homepage"),
"should reject `{bad}`: {:?}",
errs.fields
);
}
}
#[test]
fn regex_validator_substitutes_field_in_message() {
let f = Field::text("invoice_id")
.regex(r"^INV-\d{6}$", "{field} must match the invoice pattern");
let mut errs = ValidationErrors::new();
f.validate("not-an-invoice", &mut errs);
assert_eq!(
errs.fields["invoice_id"][0],
"invoice_id must match the invoice pattern"
);
}
#[test]
fn regex_validator_composes_with_required_and_max_length() {
let f = Field::text("code")
.max_length(8)
.regex(r"^[A-Z]{3}$", "{field} must be 3 uppercase letters");
let mut errs = ValidationErrors::new();
f.validate("", &mut errs);
assert!(
errs.fields["code"][0].contains("required"),
"empty surfaces required error first: {:?}",
errs.fields["code"][0]
);
let mut errs = ValidationErrors::new();
f.validate("HELLO", &mut errs);
assert!(
errs.fields["code"][0].contains("3 uppercase"),
"regex error fires when value is present but malformed: {:?}",
errs.fields["code"][0]
);
}
#[test]
fn form_errors_with_raw_round_trips_the_submitted_pairs() {
let mut raw = HashMap::new();
raw.insert("name".to_string(), "Bella Verifier".to_string());
raw.insert("email".to_string(), "bella@invalid".to_string());
raw.insert("phone".to_string(), "none".to_string());
let errs = FormErrors::with_raw(
WriteError::Validator {
field: "email".to_string(),
message: "email's domain must contain at least one `.`".to_string(),
},
raw.clone(),
);
assert_eq!(
errs.raw_values().get("name").map(|s| s.as_str()),
Some("Bella Verifier"),
);
assert_eq!(
errs.raw_values().get("phone").map(|s| s.as_str()),
Some("none"),
);
let json = errs.raw_as_json();
let obj = json.as_object().expect("raw_as_json is an object");
assert_eq!(
obj.get("name").and_then(|v| v.as_str()),
Some("Bella Verifier")
);
assert_eq!(obj.get("phone").and_then(|v| v.as_str()), Some("none"));
}
#[test]
fn form_errors_new_defaults_raw_to_empty_for_ad_hoc_construction() {
let errs = FormErrors::new(WriteError::Validator {
field: "form".to_string(),
message: "rate limited".to_string(),
});
assert!(errs.raw_values().is_empty());
let json = errs.raw_as_json();
assert!(json.as_object().expect("object").is_empty());
}
}