use std::collections::HashMap;
use axum::extract::{FromRequest, Request};
use axum::response::IntoResponse;
use serde::Serialize;
#[derive(Debug)]
pub struct Changeset<T> {
data: T,
errors: HashMap<String, Vec<String>>,
}
impl<T> Changeset<T> {
pub fn new(data: T) -> Self {
Self {
data,
errors: HashMap::new(),
}
}
pub const fn from_errors(data: T, errors: HashMap<String, Vec<String>>) -> Self {
Self { data, errors }
}
pub fn is_valid(&self) -> bool {
self.errors.is_empty()
}
pub fn errors_for(&self, field: &str) -> &[String] {
self.errors.get(field).map_or(&[], Vec::as_slice)
}
pub fn into_inner(self) -> T {
self.data
}
pub fn into_valid(self) -> Result<T, Self> {
if self.is_valid() {
Ok(self.data)
} else {
Err(self)
}
}
pub const fn data(&self) -> &T {
&self.data
}
pub const fn errors(&self) -> &HashMap<String, Vec<String>> {
&self.errors
}
}
impl<T: Serialize> Changeset<T> {
pub fn field_value(&self, field: &str) -> Option<String> {
let json = serde_json::to_value(&self.data).ok()?;
match json.get(field)? {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
}
pub trait IntoChangeset: Sized {
fn into_changeset(self) -> Changeset<Self>;
}
impl<T: validator::Validate> IntoChangeset for T {
fn into_changeset(self) -> Changeset<Self> {
match validator::Validate::validate(&self) {
Ok(()) => Changeset::new(self),
Err(errors) => Changeset::from_errors(self, validation_errors_to_map(&errors)),
}
}
}
pub struct ChangesetForm<T> {
pub changeset: Changeset<T>,
pub(crate) csrf_token: Option<String>,
pub(crate) csrf_field: String,
}
impl<T> ChangesetForm<T> {
pub fn blank(data: T, csrf_token: &str) -> Self {
Self {
changeset: Changeset::new(data),
csrf_token: Some(csrf_token.to_owned()),
csrf_field: "_csrf".to_owned(),
}
}
#[must_use]
pub fn without_csrf(data: T) -> Self {
Self {
changeset: Changeset::new(data),
csrf_token: None,
csrf_field: "_csrf".to_owned(),
}
}
#[must_use]
pub fn from_changeset(changeset: Changeset<T>) -> Self {
Self {
changeset,
csrf_token: None,
csrf_field: "_csrf".to_owned(),
}
}
#[must_use]
pub fn with_csrf_field(mut self, field: impl Into<String>) -> Self {
self.csrf_field = field.into();
self
}
pub fn csrf_token(&self) -> Option<&str> {
self.csrf_token.as_deref()
}
pub fn into_changeset(self) -> Changeset<T> {
self.changeset
}
pub fn into_valid(self) -> Result<T, Self> {
if self.changeset.is_valid() {
Ok(self.changeset.into_inner())
} else {
Err(self)
}
}
}
impl<T> std::ops::Deref for ChangesetForm<T> {
type Target = Changeset<T>;
fn deref(&self) -> &Self::Target {
&self.changeset
}
}
#[cfg(feature = "maud")]
impl<T: Serialize> ChangesetForm<T> {
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn form_tag(&self, action: &str, method: &str, content: maud::Markup) -> maud::Markup {
form_tag_inner(
action,
method,
&self.csrf_field,
self.csrf_token.as_deref(),
content,
)
}
pub fn text_input(&self, field: &str, label: &str) -> maud::Markup {
text_input(&self.changeset, field, label)
}
pub fn submit_button(&self, label: &str) -> maud::Markup {
submit_button(label)
}
}
impl<S, T> FromRequest<S> for ChangesetForm<T>
where
S: Send + Sync,
T: serde::de::DeserializeOwned + validator::Validate,
{
type Rejection = axum::response::Response;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let csrf_token = req
.extensions()
.get::<crate::security::CsrfToken>()
.map(|t| t.token().to_string());
let csrf_field = req
.extensions()
.get::<crate::security::csrf::CsrfFormField>()
.map_or_else(|| "_csrf".to_owned(), |f| f.0.clone());
let data: T = decode_form_body(req, state).await?;
Ok(Self {
changeset: data.into_changeset(),
csrf_token,
csrf_field,
})
}
}
async fn decode_form_body<T, S>(req: Request, state: &S) -> Result<T, axum::response::Response>
where
T: serde::de::DeserializeOwned + validator::Validate,
S: Send + Sync,
{
#[cfg(feature = "multipart")]
{
let content_type = req
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
if content_type.starts_with("multipart/form-data") {
return decode_multipart(req, state).await;
}
}
let axum::extract::Form(data) = axum::extract::Form::<T>::from_request(req, state)
.await
.map_err(IntoResponse::into_response)?;
Ok(data)
}
#[cfg(feature = "multipart")]
async fn decode_multipart<T, S>(req: Request, state: &S) -> Result<T, axum::response::Response>
where
T: serde::de::DeserializeOwned,
S: Send + Sync,
{
let mut multipart = axum::extract::Multipart::from_request(req, state)
.await
.map_err(IntoResponse::into_response)?;
let mut pairs: Vec<(String, String)> = Vec::new();
loop {
let field = multipart
.next_field()
.await
.map_err(|e| (axum::http::StatusCode::BAD_REQUEST, e.to_string()).into_response())?;
let Some(field) = field else { break };
let name = match field.name() {
Some(n) => n.to_string(),
None => continue,
};
if field.file_name().is_some() {
continue;
}
let value = field
.text()
.await
.map_err(|e| (axum::http::StatusCode::BAD_REQUEST, e.to_string()).into_response())?;
pairs.push((name, value));
}
let encoded = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(pairs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.finish();
serde_urlencoded::from_str::<T>(&encoded)
.map_err(|e| (axum::http::StatusCode::BAD_REQUEST, e.to_string()).into_response())
}
fn validation_errors_to_map(errors: &validator::ValidationErrors) -> HashMap<String, Vec<String>> {
let mut map = HashMap::new();
collect_errors(errors, "", &mut map);
map
}
fn collect_errors(
errors: &validator::ValidationErrors,
prefix: &str,
map: &mut HashMap<String, Vec<String>>,
) {
for (field, kind) in errors.errors() {
let key = if prefix.is_empty() {
(*field).to_string()
} else {
format!("{prefix}.{field}")
};
match kind {
validator::ValidationErrorsKind::Field(errs) => {
let messages: Vec<String> = errs
.iter()
.map(|e| {
e.message.as_ref().map_or_else(
|| format!("validation failed: {}", e.code),
ToString::to_string,
)
})
.collect();
map.entry(key).or_default().extend(messages);
}
validator::ValidationErrorsKind::Struct(nested) => {
collect_errors(nested, &key, map);
}
validator::ValidationErrorsKind::List(list) => {
for (idx, nested) in list {
let indexed_key = format!("{key}[{idx}]");
collect_errors(nested, &indexed_key, map);
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn form_tag(
action: &str,
method: &str,
csrf_token: Option<&str>,
content: maud::Markup,
) -> maud::Markup {
form_tag_inner(action, method, "_csrf", csrf_token, content)
}
#[cfg(feature = "maud")]
#[allow(clippy::needless_pass_by_value)]
fn form_tag_inner(
action: &str,
method: &str,
csrf_field: &str,
csrf_token: Option<&str>,
content: maud::Markup,
) -> maud::Markup {
maud::html! {
form action=(action) method=(method) {
@if let Some(token) = csrf_token {
input type="hidden" name=(csrf_field) value=(token);
}
(content)
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn text_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
let errors = changeset.errors_for(field);
let has_errors = !errors.is_empty();
let value = changeset.field_value(field).unwrap_or_default();
let error_id = format!("{field}-error");
maud::html! {
div {
label for=(field) { (label) }
input
type="text"
id=(field)
name=(field)
value=(value)
aria-invalid=(if has_errors { "true" } else { "false" })
aria-describedby=(if has_errors { error_id.as_str() } else { "" });
@if has_errors {
div id=(error_id) role="alert" {
@for error in errors {
p { (error) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn submit_button(label: &str) -> maud::Markup {
maud::html! {
button type="submit" { (label) }
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn password_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
let errors = changeset.errors_for(field);
let has_errors = !errors.is_empty();
let error_id = format!("{field}-error");
maud::html! {
div {
label for=(field) { (label) }
input
type="password"
id=(field)
name=(field)
aria-invalid=(if has_errors { "true" } else { "false" })
aria-describedby=(if has_errors { error_id.as_str() } else { "" });
@if has_errors {
div id=(error_id) role="alert" {
@for error in errors {
p { (error) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn textarea_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
let errors = changeset.errors_for(field);
let has_errors = !errors.is_empty();
let value = changeset.field_value(field).unwrap_or_default();
let error_id = format!("{field}-error");
maud::html! {
div {
label for=(field) { (label) }
textarea
id=(field)
name=(field)
aria-invalid=(if has_errors { "true" } else { "false" })
aria-describedby=(if has_errors { error_id.as_str() } else { "" })
{ (value) }
@if has_errors {
div id=(error_id) role="alert" {
@for error in errors {
p { (error) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_text_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
let errors = changeset.errors_for(field);
let has_errors = !errors.is_empty();
let value = changeset.field_value(field).unwrap_or_default();
let error_id = format!("{field}-error");
maud::html! {
div {
label for=(field) { (label) }
input
type="text"
id=(field)
name=(field)
value=(value)
required
aria-required="true"
aria-invalid=(if has_errors { "true" } else { "false" })
aria-describedby=(if has_errors { error_id.as_str() } else { "" });
@if has_errors {
div id=(error_id) role="alert" {
@for error in errors {
p { (error) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn aria_live_region(id: &str, message: &str) -> maud::Markup {
maud::html! {
div id=(id) role="status" aria-live="polite" aria-atomic="true" {
(message)
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn skip_link(target: &str, label: &str) -> maud::Markup {
maud::html! {
a href=(target) class="skip-link" { (label) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_changeset_is_valid() {
let cs = Changeset::new(42_i32);
assert!(cs.is_valid());
}
#[test]
fn new_changeset_has_no_errors() {
let cs = Changeset::new("hello");
assert!(cs.errors().is_empty());
}
#[test]
fn new_changeset_into_inner() {
let cs = Changeset::new(99_u8);
assert_eq!(cs.into_inner(), 99);
}
#[test]
fn new_changeset_data_ref() {
let cs = Changeset::new(vec![1, 2, 3]);
assert_eq!(cs.data(), &vec![1, 2, 3]);
}
#[test]
fn from_errors_changeset_is_invalid() {
let mut errors = HashMap::new();
errors.insert("name".to_string(), vec!["too short".to_string()]);
let cs = Changeset::from_errors("data", errors);
assert!(!cs.is_valid());
}
#[test]
fn from_errors_returns_correct_field_errors() {
let mut errors = HashMap::new();
errors.insert("email".to_string(), vec!["invalid email".to_string()]);
let cs = Changeset::from_errors("data", errors);
assert_eq!(cs.errors_for("email"), &["invalid email"]);
}
#[test]
fn errors_for_unknown_field_returns_empty_slice() {
let cs = Changeset::new("data");
assert!(cs.errors_for("nonexistent").is_empty());
}
#[test]
fn from_errors_multiple_messages_per_field() {
let mut errors = HashMap::new();
errors.insert(
"password".to_string(),
vec!["too short".to_string(), "must contain a digit".to_string()],
);
let cs = Changeset::from_errors("data", errors);
let msgs = cs.errors_for("password");
assert_eq!(msgs.len(), 2);
assert!(msgs.contains(&"too short".to_string()));
assert!(msgs.contains(&"must contain a digit".to_string()));
}
#[test]
fn into_valid_returns_ok_when_valid() {
let cs = Changeset::new(42_i32);
assert_eq!(cs.into_valid().unwrap(), 42);
}
#[test]
fn into_valid_returns_err_when_invalid() {
let mut errors = HashMap::new();
errors.insert("x".to_string(), vec!["err".to_string()]);
let cs = Changeset::from_errors(42_i32, errors);
assert!(cs.into_valid().is_err());
}
#[test]
fn into_valid_err_preserves_changeset() {
let mut errors = HashMap::new();
errors.insert("name".to_string(), vec!["required".to_string()]);
let cs = Changeset::from_errors(7_i32, errors);
let err_cs = cs.into_valid().unwrap_err();
assert_eq!(err_cs.into_inner(), 7);
}
#[test]
fn field_value_returns_string_field() {
#[derive(serde::Serialize)]
struct Form {
name: String,
}
let cs = Changeset::new(Form {
name: "Alice".into(),
});
assert_eq!(cs.field_value("name"), Some("Alice".to_string()));
}
#[test]
fn field_value_returns_number_as_string() {
#[derive(serde::Serialize)]
struct Form {
age: u32,
}
let cs = Changeset::new(Form { age: 30 });
assert_eq!(cs.field_value("age"), Some("30".to_string()));
}
#[test]
fn field_value_returns_bool_as_string() {
#[derive(serde::Serialize)]
struct Form {
active: bool,
}
let cs = Changeset::new(Form { active: true });
assert_eq!(cs.field_value("active"), Some("true".to_string()));
}
#[test]
fn field_value_returns_none_for_missing_field() {
#[derive(serde::Serialize)]
struct Form {
name: String,
}
let cs = Changeset::new(Form {
name: "Alice".into(),
});
assert_eq!(cs.field_value("email"), None);
}
#[test]
fn field_value_after_errors_uses_submitted_data() {
#[derive(serde::Serialize)]
struct Form {
name: String,
}
let mut errors = HashMap::new();
errors.insert("name".to_string(), vec!["too short".to_string()]);
let cs = Changeset::from_errors(Form { name: "ab".into() }, errors);
assert_eq!(cs.field_value("name"), Some("ab".to_string()));
}
#[test]
fn into_changeset_valid_input_produces_no_errors() {
#[derive(validator::Validate)]
struct F {
#[validate(length(min = 3))]
name: String,
}
let cs = F {
name: "Alice".into(),
}
.into_changeset();
assert!(cs.is_valid());
assert!(cs.errors_for("name").is_empty());
}
#[test]
fn into_changeset_invalid_input_populates_errors() {
#[derive(validator::Validate)]
struct F {
#[validate(length(min = 5))]
name: String,
}
let cs = F { name: "ab".into() }.into_changeset();
assert!(!cs.is_valid());
assert!(!cs.errors_for("name").is_empty());
}
#[test]
fn into_changeset_preserves_data_on_failure() {
#[derive(validator::Validate)]
struct F {
#[validate(length(min = 5))]
name: String,
}
let cs = F { name: "ab".into() }.into_changeset();
assert_eq!(cs.data().name, "ab");
}
#[test]
fn into_changeset_multiple_fields_errors() {
#[derive(validator::Validate)]
struct F {
#[validate(length(min = 3))]
name: String,
#[validate(email)]
email: String,
}
let cs = F {
name: "a".into(),
email: "not-email".into(),
}
.into_changeset();
assert!(!cs.is_valid());
assert!(!cs.errors_for("name").is_empty());
assert!(!cs.errors_for("email").is_empty());
}
mod nested_validation {
use super::*;
use validator::Validate as _;
#[derive(validator::Validate)]
struct NestedAddress {
#[validate(length(min = 3, message = "street too short"))]
street: String,
}
#[derive(validator::Validate)]
struct PersonWithAddress {
#[validate(nested)]
address: NestedAddress,
}
#[test]
fn nested_struct_errors_are_flattened_with_dot_notation() {
let cs = PersonWithAddress {
address: NestedAddress { street: "x".into() },
}
.into_changeset();
assert!(!cs.is_valid());
assert!(!cs.errors_for("address.street").is_empty());
}
}
#[test]
fn changeset_form_blank_is_valid() {
#[derive(validator::Validate, serde::Serialize)]
struct F {
#[validate(length(min = 1))]
name: String,
}
let form = ChangesetForm::blank(F { name: "ok".into() }, "tok");
assert!(form.is_valid()); assert_eq!(form.csrf_token(), Some("tok"));
}
#[test]
fn changeset_form_deref_exposes_changeset_methods() {
#[derive(validator::Validate)]
struct F {
#[validate(length(min = 3))]
name: String,
}
let changeset = F { name: "ab".into() }.into_changeset();
let form = ChangesetForm {
changeset,
csrf_token: None,
csrf_field: "_csrf".into(),
};
assert!(!form.is_valid());
assert!(!form.errors_for("name").is_empty());
}
#[test]
fn changeset_form_into_valid_ok() {
#[derive(validator::Validate)]
struct F {
#[validate(length(min = 1))]
name: String,
}
let form = ChangesetForm {
changeset: F { name: "ok".into() }.into_changeset(),
csrf_token: None,
csrf_field: "_csrf".into(),
};
assert!(form.into_valid().is_ok());
}
#[test]
fn changeset_form_into_valid_err_preserves_csrf() {
#[derive(Debug, validator::Validate)]
struct F {
#[validate(length(min = 5))]
name: String,
}
let form = ChangesetForm {
changeset: F { name: "ab".into() }.into_changeset(),
csrf_token: Some("tok123".into()),
csrf_field: "_csrf".into(),
};
let err_form = form.into_valid().unwrap_err();
assert_eq!(err_form.csrf_token(), Some("tok123"));
}
#[cfg(feature = "maud")]
#[test]
fn form_tag_renders_action_and_method() {
let html = form_tag("/users", "post", None, maud::html! { "" }).into_string();
assert!(html.contains(r#"action="/users""#), "{html}");
assert!(html.contains(r#"method="post""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_tag_emits_csrf_hidden_input_when_token_provided() {
let html = form_tag("/users", "post", Some("tok123"), maud::html! { "" }).into_string();
assert!(html.contains(r#"name="_csrf""#), "{html}");
assert!(html.contains(r#"value="tok123""#), "{html}");
assert!(html.contains(r#"type="hidden""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_tag_omits_csrf_input_when_none() {
let html = form_tag("/users", "post", None, maud::html! { "" }).into_string();
assert!(!html.contains("_csrf"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_tag_includes_content() {
let html = form_tag("/x", "post", None, maud::html! { span { "inner" } }).into_string();
assert!(html.contains("inner"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn changeset_form_form_tag_injects_stored_csrf() {
#[derive(validator::Validate, serde::Serialize)]
struct F {
name: String,
}
let form = ChangesetForm::blank(
F {
name: String::new(),
},
"secret-token",
);
let html = form
.form_tag("/x", "post", maud::html! { "" })
.into_string();
assert!(html.contains(r#"value="secret-token""#), "{html}");
assert!(html.contains(r#"name="_csrf""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn changeset_form_form_tag_honours_custom_csrf_field_name() {
#[derive(validator::Validate, serde::Serialize)]
struct F {
name: String,
}
let form = ChangesetForm {
changeset: Changeset::new(F {
name: String::new(),
}),
csrf_token: Some("tok".into()),
csrf_field: "authenticity_token".into(),
};
let html = form
.form_tag("/x", "post", maud::html! { "" })
.into_string();
assert!(html.contains(r#"name="authenticity_token""#), "{html}");
assert!(!html.contains(r#"name="_csrf""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_renders_label_name_and_value() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: "Alice".into(),
});
let html = text_input(&cs, "name", "Full Name").into_string();
assert!(html.contains(r#"name="name""#), "{html}");
assert!(html.contains(r#"value="Alice""#), "{html}");
assert!(html.contains("Full Name"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_aria_invalid_false_when_no_errors() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: "Alice".into(),
});
let html = text_input(&cs, "name", "Name").into_string();
assert!(html.contains(r#"aria-invalid="false""#), "{html}");
assert!(!html.contains(r#"role="alert""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_aria_invalid_true_and_error_block_on_failure() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let mut errors = HashMap::new();
errors.insert("name".to_string(), vec!["too short".to_string()]);
let cs = Changeset::from_errors(F { name: "ab".into() }, errors);
let html = text_input(&cs, "name", "Name").into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("too short"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_error_block_has_describedby_link() {
#[derive(serde::Serialize)]
struct F {
email: String,
}
let mut errors = HashMap::new();
errors.insert("email".to_string(), vec!["invalid".to_string()]);
let cs = Changeset::from_errors(F { email: "x".into() }, errors);
let html = text_input(&cs, "email", "Email").into_string();
assert!(html.contains("email-error"), "{html}");
assert!(html.contains(r#"aria-describedby="email-error""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_multiple_errors_all_rendered() {
#[derive(serde::Serialize)]
struct F {
password: String,
}
let mut errors = HashMap::new();
errors.insert(
"password".to_string(),
vec!["too short".to_string(), "needs digit".to_string()],
);
let cs = Changeset::from_errors(
F {
password: "x".into(),
},
errors,
);
let html = text_input(&cs, "password", "Password").into_string();
assert!(html.contains("too short"), "{html}");
assert!(html.contains("needs digit"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn submit_button_renders_button_with_label() {
let html = submit_button("Save").into_string();
assert!(html.contains(r#"type="submit""#), "{html}");
assert!(html.contains("Save"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn password_input_renders_type_password() {
#[derive(serde::Serialize)]
struct F {
password: String,
}
let cs = Changeset::new(F {
password: String::new(),
});
let html = password_input(&cs, "password", "Password").into_string();
assert!(html.contains(r#"type="password""#), "{html}");
assert!(html.contains(r#"name="password""#), "{html}");
assert!(html.contains("Password"), "{html}");
assert!(!html.contains(r#"value=""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn password_input_emits_aria_invalid_on_error() {
#[derive(serde::Serialize)]
struct F {
password: String,
}
let mut errors = HashMap::new();
errors.insert("password".to_string(), vec!["too short".to_string()]);
let cs = Changeset::from_errors(
F {
password: "x".into(),
},
errors,
);
let html = password_input(&cs, "password", "Password").into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("too short"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn textarea_input_renders_textarea_element() {
#[derive(serde::Serialize)]
struct F {
bio: String,
}
let cs = Changeset::new(F {
bio: "Hello world".into(),
});
let html = textarea_input(&cs, "bio", "Bio").into_string();
assert!(html.contains("<textarea"), "{html}");
assert!(html.contains(r#"name="bio""#), "{html}");
assert!(html.contains(r#"id="bio""#), "{html}");
assert!(html.contains("Bio"), "{html}");
assert!(html.contains("Hello world"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn textarea_input_aria_invalid_on_error() {
#[derive(serde::Serialize)]
struct F {
bio: String,
}
let mut errors = HashMap::new();
errors.insert("bio".to_string(), vec!["required".to_string()]);
let cs = Changeset::from_errors(F { bio: String::new() }, errors);
let html = textarea_input(&cs, "bio", "Bio").into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("required"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_text_input_emits_aria_required() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: "Alice".into(),
});
let html = required_text_input(&cs, "name", "Name").into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("required"), "{html}");
assert!(html.contains(r#"name="name""#), "{html}");
assert!(html.contains("Name"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_text_input_preserves_error_handling() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let mut errors = HashMap::new();
errors.insert("name".to_string(), vec!["required".to_string()]);
let cs = Changeset::from_errors(
F {
name: String::new(),
},
errors,
);
let html = required_text_input(&cs, "name", "Name").into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn aria_live_region_renders_role_status() {
let html = aria_live_region("status-msg", "").into_string();
assert!(html.contains(r#"role="status""#), "{html}");
assert!(html.contains(r#"aria-live="polite""#), "{html}");
assert!(html.contains(r#"id="status-msg""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn aria_live_region_renders_message_content() {
let html = aria_live_region("status-msg", "Form submitted").into_string();
assert!(html.contains("Form submitted"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn skip_link_renders_anchor_with_href() {
let html = skip_link("#main-content", "Skip to main content").into_string();
assert!(html.contains(r##"href="#main-content""##), "{html}");
assert!(html.contains("Skip to main content"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn skip_link_has_visually_hidden_class_for_focus_reveal() {
let html = skip_link("#main", "Skip").into_string();
assert!(html.contains("skip-link"), "{html}");
}
mod extractor_tests {
use super::*;
use axum::{Router, body::Body, routing::post};
use tower::ServiceExt;
#[derive(serde::Deserialize, validator::Validate)]
struct TestForm {
#[validate(length(min = 3))]
name: String,
}
#[tokio::test]
async fn valid_form_body_produces_valid_changeset() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
format!("valid={}", form.is_valid())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice"))
.await
.unwrap();
assert_body(resp, "valid=true").await;
}
#[tokio::test]
async fn invalid_form_body_produces_invalid_changeset() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
format!("valid={}", form.is_valid())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=ab"))
.await
.unwrap();
assert_body(resp, "valid=false").await;
}
#[tokio::test]
async fn invalid_form_exposes_field_errors() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
form.errors_for("name").join("|")
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=ab"))
.await
.unwrap();
let body = body_text(resp).await;
assert!(!body.is_empty(), "expected errors, got empty string");
}
#[tokio::test]
async fn missing_required_field_returns_non_200() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
format!("valid={}", form.is_valid())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "other=value"))
.await
.unwrap();
assert_ne!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn csrf_token_is_none_without_csrf_middleware() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
form.csrf_token().unwrap_or("none").to_string()
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice"))
.await
.unwrap();
assert_body(resp, "none").await;
}
#[tokio::test]
async fn csrf_token_captured_from_request_extensions() {
use crate::security::CsrfToken;
let mut req = axum::http::Request::builder()
.method("POST")
.uri("/test")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from("name=Alice"))
.unwrap();
req.extensions_mut()
.insert(CsrfToken::new("secret-tok".to_string()));
let form = ChangesetForm::<TestForm>::from_request(req, &())
.await
.expect("extraction should succeed");
assert_eq!(form.csrf_token(), Some("secret-tok"));
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_form_decodes_text_fields() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
format!("valid={} name={}", form.is_valid(), form.data().name)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(multipart_req("/test", "name", "Alice"))
.await
.unwrap();
assert_body(resp, "valid=true name=Alice").await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_form_validates_fields() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
format!("valid={}", form.is_valid())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(multipart_req("/test", "name", "ab"))
.await
.unwrap();
assert_body(resp, "valid=false").await;
}
fn urlencoded_req(uri: &str, body: &'static str) -> axum::http::Request<Body> {
axum::http::Request::builder()
.method("POST")
.uri(uri)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap()
}
#[cfg(feature = "multipart")]
fn multipart_req(uri: &str, field: &str, value: &str) -> axum::http::Request<Body> {
let boundary = "----FormBoundary7MA4YWxkTrZu0gW";
let body = format!(
"--{boundary}\r\n\
Content-Disposition: form-data; name=\"{field}\"\r\n\r\n\
{value}\r\n\
--{boundary}--\r\n"
);
axum::http::Request::builder()
.method("POST")
.uri(uri)
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap()
}
async fn body_text(resp: axum::response::Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
async fn assert_body(resp: axum::response::Response, expected: &str) {
assert_eq!(body_text(resp).await, expected);
}
}
}