#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
)
)]
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)
}
#[must_use]
pub const fn all_errors(&self) -> &HashMap<String, Vec<String>> {
&self.errors
}
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(),
None,
content,
)
}
pub fn text_input(&self, field: &str, label: &str) -> maud::Markup {
text_input(&self.changeset, field, label)
}
pub fn text_input_htmx(&self, field: &str, label: &str, validate_url: &str) -> maud::Markup {
text_input_htmx(&self.changeset, field, label, validate_url)
}
pub fn text_input_htmx_with_token_field(
&self,
field: &str,
label: &str,
validate_url: &str,
token_field: &str,
) -> maud::Markup {
text_input_htmx_with_token_field(&self.changeset, field, label, validate_url, token_field)
}
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 + Send,
{
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 + Send,
S: Send + Sync,
{
let content_type = req
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
#[cfg(feature = "multipart")]
if content_type.starts_with("multipart/form-data") {
return decode_multipart(req, state).await;
}
if !content_type.starts_with("application/x-www-form-urlencoded") {
return Err((
axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
"Form requests must have `Content-Type: application/x-www-form-urlencoded`",
)
.into_response());
}
let (parts, body) = req.into_parts();
let bytes_req = Request::from_parts(parts, body);
let bytes = axum::body::Bytes::from_request(bytes_req, state)
.await
.map_err(IntoResponse::into_response)?;
decode_urlencoded_dropping_blank_optional_fields::<T>(&bytes)
.map_err(|e| (axum::http::StatusCode::BAD_REQUEST, e.to_string()).into_response())
}
pub(crate) fn decode_urlencoded_dropping_blank_optional_fields<T: serde::de::DeserializeOwned>(
bytes: &[u8],
) -> Result<T, serde_path_to_error::Error<serde_urlencoded::de::Error>> {
let mut pairs: Vec<(String, String)> = url::form_urlencoded::parse(bytes)
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
loop {
let encoded = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(pairs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.finish();
let deserializer =
serde_urlencoded::Deserializer::new(url::form_urlencoded::parse(encoded.as_bytes()));
match serde_path_to_error::deserialize(deserializer) {
Ok(data) => return Ok(data),
Err(err) => {
let field = err.path().to_string();
let Some(pos) = pairs.iter().position(|(k, v)| *k == field && v.is_empty()) else {
return Err(err);
};
pairs.remove(pos);
}
}
}
}
#[cfg(fuzzing)]
pub fn __fuzz_decode_urlencoded(bytes: &[u8]) {
let _ = decode_urlencoded_dropping_blank_optional_fields::<
std::collections::HashMap<String, String>,
>(bytes);
}
#[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();
match serde_urlencoded::from_str::<T>(&encoded) {
Ok(data) => Ok(data),
Err(first_err) => {
let (blank, non_blank): (Vec<_>, Vec<_>) =
pairs.iter().partition(|(_, v)| v.is_empty());
if blank.is_empty() {
return Err(
(axum::http::StatusCode::BAD_REQUEST, first_err.to_string()).into_response()
);
}
let filtered = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(non_blank.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.finish();
serde_urlencoded::from_str::<T>(&filtered).map_err(|_| {
(axum::http::StatusCode::BAD_REQUEST, first_err.to_string()).into_response()
})
}
}
}
pub(crate) 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, None, content)
}
#[cfg(feature = "maud")]
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn form_tag_inner(
action: &str,
method: &str,
csrf_field: &str,
csrf_token: Option<&str>,
enctype: Option<&str>,
content: maud::Markup,
) -> maud::Markup {
let (browser_method, override_value) = browser_method_and_override(method);
maud::html! {
form action=(action) method=(browser_method) enctype=[enctype] {
@if let Some(override_method) = override_value {
input
type="hidden"
name=(crate::middleware::DEFAULT_METHOD_OVERRIDE_FIELD)
value=(override_method);
}
@if let Some(token) = csrf_token {
input type="hidden" name=(csrf_field) value=(token);
}
(content)
}
}
}
#[cfg(feature = "maud")]
fn browser_method_and_override(method: &str) -> (&'static str, Option<&'static str>) {
let trimmed = method.trim();
if trimmed.eq_ignore_ascii_case("PUT") {
("post", Some("PUT"))
} else if trimmed.eq_ignore_ascii_case("PATCH") {
("post", Some("PATCH"))
} else if trimmed.eq_ignore_ascii_case("DELETE") {
("post", Some("DELETE"))
} else if trimmed.eq_ignore_ascii_case("GET") {
("get", None)
} else {
("post", None)
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn method_input(method: &str) -> maud::Markup {
let normalized = method.trim();
let value = if normalized.eq_ignore_ascii_case("PUT") {
"PUT"
} else if normalized.eq_ignore_ascii_case("PATCH") {
"PATCH"
} else if normalized.eq_ignore_ascii_case("DELETE") {
"DELETE"
} else {
return maud::html! {};
};
maud::html! {
input
type="hidden"
name=(crate::middleware::DEFAULT_METHOD_OVERRIDE_FIELD)
value=(value);
}
}
#[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");
let wrapper_id = format!("{field}-field");
maud::html! {
div id=(wrapper_id) class="autumn-field" {
label for=(field) class="autumn-field__label" { (label) }
input
type="text"
id=(field)
name=(field)
value=(value)
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
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" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn text_input_htmx<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
validate_url: &str,
) -> maud::Markup {
text_input_htmx_with_token_field(
changeset,
field,
label,
validate_url,
DEFAULT_SUBMIT_TOKEN_FIELD,
)
}
#[cfg(feature = "maud")]
const DEFAULT_SUBMIT_TOKEN_FIELD: &str = "_submit_token";
#[cfg(feature = "maud")]
#[must_use]
pub fn text_input_htmx_with_token_field<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
validate_url: &str,
token_field: &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");
let wrapper_id = format!("{field}-field");
let target = "closest [data-autumn-field-wrapper]";
let hx_params = format!("not {token_field}");
maud::html! {
div id=(wrapper_id) class="autumn-field" data-autumn-field-wrapper=(field) {
label for=(field) class="autumn-field__label" { (label) }
input
type="text"
id=(field)
name=(field)
value=(value)
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
aria-invalid=(if has_errors { "true" } else { "false" })
aria-describedby=(if has_errors { error_id.as_str() } else { "" })
hx-post=(validate_url)
hx-trigger="change"
hx-target=(target)
hx-swap="outerHTML"
hx-include="closest form"
hx-params=(hx_params);
@if has_errors {
div id=(error_id) role="alert" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_text_input_htmx<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
validate_url: &str,
) -> maud::Markup {
required_text_input_htmx_with_token_field(
changeset,
field,
label,
validate_url,
DEFAULT_SUBMIT_TOKEN_FIELD,
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_text_input_htmx_with_token_field<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
validate_url: &str,
token_field: &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");
let wrapper_id = format!("{field}-field");
let target = "closest [data-autumn-field-wrapper]";
let hx_params = format!("not {token_field}");
maud::html! {
div id=(wrapper_id) class="autumn-field" data-autumn-field-wrapper=(field) {
label for=(field) class="autumn-field__label" { (label) }
input
type="text"
id=(field)
name=(field)
value=(value)
required
aria-required="true"
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
aria-invalid=(if has_errors { "true" } else { "false" })
aria-describedby=(if has_errors { error_id.as_str() } else { "" })
hx-post=(validate_url)
hx-trigger="change"
hx-target=(target)
hx-swap="outerHTML"
hx-include="closest form"
hx-params=(hx_params);
@if has_errors {
div id=(error_id) role="alert" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn submit_button(label: &str) -> maud::Markup {
maud::html! {
button type="submit" class="autumn-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");
let wrapper_id = format!("{field}-field");
maud::html! {
div id=(wrapper_id) class="autumn-field" {
label for=(field) class="autumn-field__label" { (label) }
input
type="password"
id=(field)
name=(field)
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
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" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (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");
let wrapper_id = format!("{field}-field");
maud::html! {
div id=(wrapper_id) class="autumn-field" {
label for=(field) class="autumn-field__label" { (label) }
textarea
id=(field)
name=(field)
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
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" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (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");
let wrapper_id = format!("{field}-field");
maud::html! {
div id=(wrapper_id) {
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 checkbox_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 checked = changeset.field_value(field).as_deref() == Some("true");
let error_id = format!("{field}-error");
let wrapper_id = format!("{field}-field");
maud::html! {
div id=(wrapper_id) class="autumn-field" {
label for=(field) class="autumn-field__label" { (label) }
input
type="checkbox"
id=(field)
name=(field)
value="true"
checked[checked]
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
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" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn number_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
step: Option<&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");
let wrapper_id = format!("{field}-field");
maud::html! {
div id=(wrapper_id) class="autumn-field" {
label for=(field) class="autumn-field__label" { (label) }
input
type="number"
id=(field)
name=(field)
value=(value)
step=[step]
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
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" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_number_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
step: Option<&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");
let wrapper_id = format!("{field}-field");
maud::html! {
div id=(wrapper_id) class="autumn-field" {
label for=(field) class="autumn-field__label" { (label) }
input
type="number"
id=(field)
name=(field)
value=(value)
step=[step]
required
aria-required="true"
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
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" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
fn normalize_date_value(raw: &str) -> String {
if raw.is_empty() {
return String::new();
}
let normalized = raw.replace(' ', "T");
if let Ok(date) = chrono::NaiveDate::parse_from_str(&normalized, "%Y-%m-%d") {
return date.to_string();
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&normalized) {
return dt.format("%Y-%m-%d").to_string();
}
if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(&normalized, "%Y-%m-%dT%H:%M:%S%.f") {
return ndt.format("%Y-%m-%d").to_string();
}
raw.to_owned()
}
#[cfg(feature = "maud")]
fn normalize_datetime_local_value(raw: &str) -> String {
if raw.is_empty() {
return String::new();
}
let normalized = raw.replace(' ', "T");
if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(&normalized, "%Y-%m-%dT%H:%M:%S%.f") {
return ndt.format("%Y-%m-%dT%H:%M:%S%.f").to_string();
}
if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(&normalized, "%Y-%m-%dT%H:%M") {
return ndt.format("%Y-%m-%dT%H:%M:%S%.f").to_string();
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&normalized) {
return dt.naive_local().format("%Y-%m-%dT%H:%M:%S%.f").to_string();
}
raw.to_owned()
}
fn pad_datetime_local_seconds(raw: &str) -> String {
if raw.chars().count() == 16 {
format!("{raw}:00")
} else {
raw.to_owned()
}
}
fn parse_datetime_local_or_rfc3339_utc(
raw: &str,
) -> Result<chrono::DateTime<chrono::Utc>, chrono::ParseError> {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(raw) {
return Ok(dt.with_timezone(&chrono::Utc));
}
chrono::NaiveDateTime::parse_from_str(&pad_datetime_local_seconds(raw), "%Y-%m-%dT%H:%M:%S%.f")
.map(|ndt| ndt.and_utc())
}
fn parse_datetime_local_or_rfc3339_local(
raw: &str,
) -> Result<chrono::DateTime<chrono::Local>, String> {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(raw) {
return Ok(dt.with_timezone(&chrono::Local));
}
let ndt = chrono::NaiveDateTime::parse_from_str(
&pad_datetime_local_seconds(raw),
"%Y-%m-%dT%H:%M:%S%.f",
)
.map_err(|e| e.to_string())?;
match ndt.and_local_timezone(chrono::Local) {
chrono::LocalResult::Single(dt) => Ok(dt),
chrono::LocalResult::Ambiguous(earliest, _) => Ok(earliest),
chrono::LocalResult::None => Err(format!(
"local time {ndt} does not exist in the server's timezone (skipped by a DST transition)"
)),
}
}
pub fn deserialize_datetime_local_utc<'de, D>(
deserializer: D,
) -> Result<chrono::DateTime<chrono::Utc>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = <String as serde::Deserialize>::deserialize(deserializer)?;
parse_datetime_local_or_rfc3339_utc(&raw).map_err(serde::de::Error::custom)
}
pub fn deserialize_datetime_local_utc_option<'de, D>(
deserializer: D,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = <Option<String> as serde::Deserialize>::deserialize(deserializer)?;
match raw {
Some(s) if !s.is_empty() => parse_datetime_local_or_rfc3339_utc(&s)
.map(Some)
.map_err(serde::de::Error::custom),
_ => Ok(None),
}
}
pub fn deserialize_datetime_local_local<'de, D>(
deserializer: D,
) -> Result<chrono::DateTime<chrono::Local>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = <String as serde::Deserialize>::deserialize(deserializer)?;
parse_datetime_local_or_rfc3339_local(&raw).map_err(serde::de::Error::custom)
}
pub fn deserialize_datetime_local_local_option<'de, D>(
deserializer: D,
) -> Result<Option<chrono::DateTime<chrono::Local>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = <Option<String> as serde::Deserialize>::deserialize(deserializer)?;
match raw {
Some(s) if !s.is_empty() => parse_datetime_local_or_rfc3339_local(&s)
.map(Some)
.map_err(serde::de::Error::custom),
_ => Ok(None),
}
}
pub fn deserialize_naive_datetime_local<'de, D>(
deserializer: D,
) -> Result<chrono::NaiveDateTime, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = <String as serde::Deserialize>::deserialize(deserializer)?;
chrono::NaiveDateTime::parse_from_str(&pad_datetime_local_seconds(&raw), "%Y-%m-%dT%H:%M:%S%.f")
.map_err(serde::de::Error::custom)
}
pub fn deserialize_naive_datetime_local_option<'de, D>(
deserializer: D,
) -> Result<Option<chrono::NaiveDateTime>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = <Option<String> as serde::Deserialize>::deserialize(deserializer)?;
match raw {
Some(s) if !s.is_empty() => chrono::NaiveDateTime::parse_from_str(
&pad_datetime_local_seconds(&s),
"%Y-%m-%dT%H:%M:%S%.f",
)
.map(Some)
.map_err(serde::de::Error::custom),
_ => Ok(None),
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn date_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 = normalize_date_value(&changeset.field_value(field).unwrap_or_default());
let error_id = format!("{field}-error");
let wrapper_id = format!("{field}-field");
maud::html! {
div id=(wrapper_id) class="autumn-field" {
label for=(field) class="autumn-field__label" { (label) }
input
type="date"
id=(field)
name=(field)
value=(value)
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
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" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_date_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 = normalize_date_value(&changeset.field_value(field).unwrap_or_default());
let error_id = format!("{field}-error");
let wrapper_id = format!("{field}-field");
maud::html! {
div id=(wrapper_id) class="autumn-field" {
label for=(field) class="autumn-field__label" { (label) }
input
type="date"
id=(field)
name=(field)
value=(value)
required
aria-required="true"
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
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" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn datetime_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 = normalize_datetime_local_value(&changeset.field_value(field).unwrap_or_default());
let error_id = format!("{field}-error");
let wrapper_id = format!("{field}-field");
maud::html! {
div id=(wrapper_id) class="autumn-field" {
label for=(field) class="autumn-field__label" { (label) }
input
type="datetime-local"
id=(field)
name=(field)
value=(value)
step="any"
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
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" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_datetime_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 = normalize_datetime_local_value(&changeset.field_value(field).unwrap_or_default());
let error_id = format!("{field}-error");
let wrapper_id = format!("{field}-field");
maud::html! {
div id=(wrapper_id) class="autumn-field" {
label for=(field) class="autumn-field__label" { (label) }
input
type="datetime-local"
id=(field)
name=(field)
value=(value)
step="any"
required
aria-required="true"
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
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" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn select_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &[(&str, &str)],
) -> maud::Markup {
let errors = changeset.errors_for(field);
let has_errors = !errors.is_empty();
let current = changeset.field_value(field).unwrap_or_default();
let error_id = format!("{field}-error");
let wrapper_id = format!("{field}-field");
maud::html! {
div id=(wrapper_id) class="autumn-field" {
label for=(field) class="autumn-field__label" { (label) }
select
id=(field)
name=(field)
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
aria-invalid=(if has_errors { "true" } else { "false" })
aria-describedby=(if has_errors { error_id.as_str() } else { "" }) {
@for (option_value, option_label) in options {
option value=(option_value) selected[*option_value == current] { (option_label) }
}
}
@if has_errors {
div id=(error_id) role="alert" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_select_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &[(&str, &str)],
) -> maud::Markup {
let errors = changeset.errors_for(field);
let has_errors = !errors.is_empty();
let current = changeset.field_value(field).unwrap_or_default();
let error_id = format!("{field}-error");
let wrapper_id = format!("{field}-field");
maud::html! {
div id=(wrapper_id) class="autumn-field" {
label for=(field) class="autumn-field__label" { (label) }
select
id=(field)
name=(field)
required
aria-required="true"
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
aria-invalid=(if has_errors { "true" } else { "false" })
aria-describedby=(if has_errors { error_id.as_str() } else { "" }) {
@for (option_value, option_label) in options {
option value=(option_value) selected[*option_value == current] { (option_label) }
}
}
@if has_errors {
div id=(error_id) role="alert" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (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) }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum FieldControl {
Text,
Textarea,
Password,
Number {
step: Option<String>,
},
Checkbox,
Date,
DateTime,
Select {
options: Vec<(String, String)>,
},
File,
}
#[derive(Debug, Clone)]
pub struct FormField {
pub name: String,
pub label: String,
pub control: FieldControl,
pub required: bool,
pub value_name: Option<String>,
}
impl FormField {
#[must_use]
pub fn new(
name: impl Into<String>,
label: impl Into<String>,
control: FieldControl,
required: bool,
) -> Self {
Self {
name: name.into(),
label: label.into(),
control,
required,
value_name: None,
}
}
#[must_use]
pub fn with_value_name(mut self, value_name: impl Into<String>) -> Self {
self.value_name = Some(value_name.into());
self
}
}
pub trait FormModel {
fn form_fields() -> Vec<FormField>;
}
#[cfg(feature = "maud")]
#[must_use]
pub fn form_for<T>(
changeset: &Changeset<T>,
action: impl Into<String>,
method: impl Into<String>,
) -> FormFor<'_, T>
where
T: Serialize + FormModel,
{
FormFor {
changeset,
action: action.into(),
method: method.into(),
csrf_token: None,
csrf_field_name: "_csrf".to_string(),
excluded: Vec::new(),
field_overrides: Vec::new(),
label_overrides: Vec::new(),
prepended: Vec::new(),
appended: Vec::new(),
submit_label: "Save".to_string(),
force_multipart: false,
}
}
#[cfg(feature = "maud")]
pub struct FormFor<'a, T: Serialize + FormModel> {
changeset: &'a Changeset<T>,
action: String,
method: String,
csrf_token: Option<String>,
csrf_field_name: String,
excluded: Vec<String>,
field_overrides: Vec<(String, FieldControl)>,
label_overrides: Vec<(String, String)>,
prepended: Vec<maud::Markup>,
appended: Vec<maud::Markup>,
submit_label: String,
force_multipart: bool,
}
#[cfg(feature = "maud")]
impl<T: Serialize + FormModel> FormFor<'_, T> {
#[must_use]
pub fn csrf(mut self, token: impl Into<String>) -> Self {
self.csrf_token = Some(token.into());
self
}
#[must_use]
pub fn csrf_field_name(mut self, name: impl Into<String>) -> Self {
self.csrf_field_name = name.into();
self
}
#[must_use]
pub fn exclude(mut self, field: impl Into<String>) -> Self {
self.excluded.push(field.into());
self
}
#[must_use]
pub fn override_field(mut self, field: impl Into<String>, control: FieldControl) -> Self {
self.field_overrides.push((field.into(), control));
self
}
#[must_use]
pub fn override_label(mut self, field: impl Into<String>, label: impl Into<String>) -> Self {
self.label_overrides.push((field.into(), label.into()));
self
}
#[must_use]
pub fn append(mut self, markup: maud::Markup) -> Self {
self.appended.push(markup);
self
}
#[must_use]
pub fn prepend(mut self, markup: maud::Markup) -> Self {
self.prepended.push(markup);
self
}
#[must_use]
pub fn submit_label(mut self, label: impl Into<String>) -> Self {
self.submit_label = label.into();
self
}
#[must_use]
pub const fn multipart(mut self) -> Self {
self.force_multipart = true;
self
}
#[must_use]
pub fn render(self) -> maud::Markup {
let Self {
changeset,
action,
method,
csrf_token,
csrf_field_name,
excluded,
field_overrides,
label_overrides,
prepended,
appended,
submit_label,
force_multipart,
} = self;
let fields = effective_form_fields::<T>(&excluded, &field_overrides, &label_overrides);
let is_multipart = force_multipart
|| fields
.iter()
.any(|f| matches!(f.control, FieldControl::File));
let inner = maud::html! {
@for markup in prepended {
(markup)
}
@for field in &fields {
(render_form_field(changeset, field))
}
@for markup in appended {
(markup)
}
button type="submit" { (submit_label) }
};
form_tag_inner(
&action,
&method,
&csrf_field_name,
csrf_token.as_deref(),
is_multipart.then_some("multipart/form-data"),
inner,
)
}
}
#[cfg(feature = "maud")]
fn effective_form_fields<T: FormModel>(
excluded: &[String],
field_overrides: &[(String, FieldControl)],
label_overrides: &[(String, String)],
) -> Vec<FormField> {
T::form_fields()
.into_iter()
.filter(|field| !excluded.contains(&field.name))
.map(|mut field| {
if let Some((_, control)) = field_overrides
.iter()
.rev()
.find(|(name, _)| *name == field.name)
{
field.control = control.clone();
}
if let Some((_, label)) = label_overrides
.iter()
.rev()
.find(|(name, _)| *name == field.name)
{
field.label = label.clone();
}
field
})
.collect()
}
#[cfg(feature = "maud")]
struct PrefillAlias<'a, T> {
data: &'a T,
serialized_name: &'a str,
exposed_name: &'a str,
}
#[cfg(feature = "maud")]
impl<T: Serialize> Serialize for PrefillAlias<'_, T> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::{Error as _, SerializeMap as _};
let value = serde_json::to_value(self.data).map_err(S::Error::custom)?;
let field_value = value
.get(self.serialized_name)
.cloned()
.unwrap_or(serde_json::Value::Null);
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry(self.exposed_name, &field_value)?;
map.end()
}
}
#[cfg(feature = "maud")]
fn wrap_field_control(
field_name: &str,
control: impl maud::Render,
errors: &[String],
) -> maud::Markup {
let error_id = format!("{field_name}-error");
let wrapper_id = format!("{field_name}-field");
maud::html! {
div id=(wrapper_id) class="autumn-field" {
(control)
@if !errors.is_empty() {
div id=(error_id) role="alert" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
const fn field_control_class(has_errors: bool) -> &'static str {
if has_errors {
"autumn-field__input autumn-field__input--invalid"
} else {
"autumn-field__input"
}
}
#[cfg(feature = "maud")]
fn render_form_field<T: Serialize>(changeset: &Changeset<T>, field: &FormField) -> maud::Markup {
if let Some(value_name) = field
.value_name
.as_deref()
.filter(|value_name| *value_name != field.name)
{
let aliased = Changeset::from_errors(
PrefillAlias {
data: changeset.data(),
serialized_name: value_name,
exposed_name: &field.name,
},
changeset.errors().clone(),
);
return render_form_control(&aliased, field);
}
render_form_control(changeset, field)
}
#[cfg(feature = "maud")]
fn render_form_control<T: Serialize>(changeset: &Changeset<T>, field: &FormField) -> maud::Markup {
match &field.control {
FieldControl::Text => {
if field.required {
required_text_input(changeset, &field.name, &field.label)
} else {
text_input(changeset, &field.name, &field.label)
}
}
FieldControl::Textarea => {
let errors = changeset.errors_for(&field.name);
let has_errors = !errors.is_empty();
let value = changeset.field_value(&field.name).unwrap_or_default();
let mut control = crate::a11y::TextArea::new(field.name.as_str())
.label(field.label.as_str())
.label_class("autumn-field__label")
.value(value)
.class(field_control_class(has_errors))
.aria_invalid(has_errors);
if has_errors {
control = control.described_by(format!("{}-error", field.name));
}
wrap_field_control(&field.name, control, errors)
}
FieldControl::Password => password_input(changeset, &field.name, &field.label),
FieldControl::Number { step } => {
if field.required {
required_number_input(changeset, &field.name, &field.label, step.as_deref())
} else {
number_input(changeset, &field.name, &field.label, step.as_deref())
}
}
FieldControl::Checkbox => {
let errors = changeset.errors_for(&field.name);
let has_errors = !errors.is_empty();
let checked = changeset.field_value(&field.name).as_deref() == Some("true");
let mut control = crate::a11y::Checkbox::new(field.name.as_str())
.label(field.label.as_str())
.label_class("autumn-field__label")
.value("true")
.checked(checked)
.class(field_control_class(has_errors))
.aria_invalid(has_errors);
if has_errors {
control = control.described_by(format!("{}-error", field.name));
}
wrap_field_control(&field.name, control, errors)
}
FieldControl::Date => {
if field.required {
required_date_input(changeset, &field.name, &field.label)
} else {
date_input(changeset, &field.name, &field.label)
}
}
FieldControl::DateTime => {
if field.required {
required_datetime_input(changeset, &field.name, &field.label)
} else {
datetime_input(changeset, &field.name, &field.label)
}
}
FieldControl::Select { options } => {
let errors = changeset.errors_for(&field.name);
let has_errors = !errors.is_empty();
let current = changeset.field_value(&field.name).unwrap_or_default();
let mut control = crate::a11y::Select::new(field.name.as_str())
.label(field.label.as_str())
.label_class("autumn-field__label")
.options(options.iter().map(|(value, label)| {
crate::a11y::SelectOption::new(value.as_str(), label.as_str())
}))
.selected_value(current)
.class(field_control_class(has_errors))
.aria_invalid(has_errors);
if field.required {
control = control.required().aria_required();
}
if has_errors {
control = control.described_by(format!("{}-error", field.name));
}
wrap_field_control(&field.name, control, errors)
}
FieldControl::File => {
let errors = changeset.errors_for(&field.name);
let has_errors = !errors.is_empty();
let mut control = crate::a11y::FileField::new(field.name.as_str())
.label(field.label.as_str())
.label_class("autumn-field__label")
.class(field_control_class(has_errors))
.aria_invalid(has_errors);
if field.required {
control = control.required().aria_required();
}
if has_errors {
control = control.described_by(format!("{}-error", field.name));
}
wrap_field_control(&field.name, control, errors)
}
}
}
#[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 form_tag_emits_method_override_for_delete() {
let html = form_tag("/posts/42", "delete", None, maud::html! { "" }).into_string();
assert!(html.contains(r#"method="post""#), "{html}");
assert!(!html.contains(r#"method="delete""#), "{html}");
assert!(html.contains(r#"name="_method""#), "{html}");
assert!(html.contains(r#"value="DELETE""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_tag_emits_method_override_for_put_and_patch() {
let put_html = form_tag("/p/1", "put", None, maud::html! { "" }).into_string();
assert!(put_html.contains(r#"method="post""#));
assert!(put_html.contains(r#"value="PUT""#));
let patch_html = form_tag("/p/1", "PATCH", None, maud::html! { "" }).into_string();
assert!(patch_html.contains(r#"method="post""#));
assert!(patch_html.contains(r#"value="PATCH""#));
}
#[cfg(feature = "maud")]
#[test]
fn form_tag_no_override_for_get_or_post() {
let get_html = form_tag("/p", "get", None, maud::html! { "" }).into_string();
assert!(!get_html.contains("_method"), "{get_html}");
let post_html = form_tag("/p", "post", None, maud::html! { "" }).into_string();
assert!(!post_html.contains("_method"), "{post_html}");
}
#[cfg(feature = "maud")]
#[test]
fn method_input_emits_hidden_field_for_mutating_methods() {
for method in ["PUT", "PATCH", "DELETE", "delete"] {
let html = method_input(method).into_string();
assert!(html.contains(r#"name="_method""#), "{html}");
assert!(html.contains(r#"type="hidden""#), "{html}");
}
}
#[cfg(feature = "maud")]
#[test]
fn method_input_is_empty_for_safe_or_unknown_methods() {
assert_eq!(method_input("GET").into_string(), "");
assert_eq!(method_input("POST").into_string(), "");
assert_eq!(method_input("BREW").into_string(), "");
}
#[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}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_wrapper_div_has_stable_id() {
#[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#"id="name-field""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn password_input_wrapper_div_has_stable_id() {
#[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#"id="password-field""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn textarea_input_wrapper_div_has_stable_id() {
#[derive(serde::Serialize)]
struct F {
bio: String,
}
let cs = Changeset::new(F {
bio: "Hello".into(),
});
let html = textarea_input(&cs, "bio", "Bio").into_string();
assert!(html.contains(r#"id="bio-field""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_text_input_wrapper_div_has_stable_id() {
#[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#"id="name-field""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_number_input_emits_aria_required() {
#[derive(serde::Serialize)]
struct F {
age: i32,
}
let cs = Changeset::new(F { age: 30 });
let html = required_number_input(&cs, "age", "Age", Some("1")).into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("required"), "{html}");
assert!(html.contains(r#"type="number""#), "{html}");
assert!(html.contains(r#"name="age""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_number_input_preserves_error_handling() {
#[derive(serde::Serialize)]
struct F {
age: i32,
}
let mut errors = HashMap::new();
errors.insert("age".to_string(), vec!["must be positive".to_string()]);
let cs = Changeset::from_errors(F { age: -1 }, errors);
let html = required_number_input(&cs, "age", "Age", Some("1")).into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("must be positive"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_datetime_input_emits_aria_required() {
#[derive(serde::Serialize)]
struct F {
scheduled_at: String,
}
let cs = Changeset::new(F {
scheduled_at: "2026-01-01T12:00:00".into(),
});
let html = required_datetime_input(&cs, "scheduled_at", "Scheduled at").into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("required"), "{html}");
assert!(html.contains(r#"type="datetime-local""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_select_input_emits_aria_required() {
#[derive(serde::Serialize)]
struct F {
status: String,
}
let cs = Changeset::new(F {
status: "draft".into(),
});
let html = required_select_input(
&cs,
"status",
"Status",
&[("draft", "Draft"), ("published", "Published")],
)
.into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("required"), "{html}");
assert!(html.contains("<select"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_select_input_preserves_error_handling() {
#[derive(serde::Serialize)]
struct F {
status: String,
}
let mut errors = HashMap::new();
errors.insert("status".to_string(), vec!["is required".to_string()]);
let cs = Changeset::from_errors(
F {
status: String::new(),
},
errors,
);
let html =
required_select_input(&cs, "status", "Status", &[("draft", "Draft")]).into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("is required"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_text_input_htmx_emits_aria_required() {
#[derive(serde::Serialize)]
struct F {
title: String,
}
let cs = Changeset::new(F {
title: "Hello".into(),
});
let html =
required_text_input_htmx(&cs, "title", "Title", "/posts/validate/title").into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("required"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_text_input_htmx_preserves_htmx_attributes() {
#[derive(serde::Serialize)]
struct F {
title: String,
}
let cs = Changeset::new(F {
title: String::new(),
});
let html =
required_text_input_htmx(&cs, "title", "Title", "/posts/validate/title").into_string();
assert!(
html.contains(r#"hx-post="/posts/validate/title""#),
"{html}"
);
assert!(html.contains(r#"hx-trigger="change""#), "{html}");
assert!(
html.contains(r#"hx-target="closest [data-autumn-field-wrapper]""#),
"{html}"
);
assert!(html.contains(r#"hx-swap="outerHTML""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_text_input_htmx_preserves_error_handling() {
#[derive(serde::Serialize)]
struct F {
title: String,
}
let mut errors = HashMap::new();
errors.insert("title".to_string(), vec!["is required".to_string()]);
let cs = Changeset::from_errors(
F {
title: String::new(),
},
errors,
);
let html =
required_text_input_htmx(&cs, "title", "Title", "/posts/validate/title").into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("is required"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_wrapper_has_stable_id() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: "Alice".into(),
});
let html = text_input_htmx(&cs, "name", "Name", "/validate/name").into_string();
assert!(html.contains(r#"id="name-field""#), "{html}");
assert!(
html.contains(r#"data-autumn-field-wrapper="name""#),
"{html}"
);
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_renders_hx_post() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: "Alice".into(),
});
let html = text_input_htmx(&cs, "name", "Name", "/validate/name").into_string();
assert!(html.contains(r#"hx-post="/validate/name""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_renders_hx_trigger_change() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: String::new(),
});
let html = text_input_htmx(&cs, "name", "Name", "/validate/name").into_string();
assert!(html.contains(r#"hx-trigger="change""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_renders_hx_target_and_swap() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: String::new(),
});
let html = text_input_htmx(&cs, "name", "Name", "/validate/name").into_string();
assert!(
html.contains(r#"hx-target="closest [data-autumn-field-wrapper]""#),
"{html}"
);
assert!(html.contains(r#"hx-swap="outerHTML""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_target_is_safe_for_nested_field_names() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: String::new(),
});
let html =
text_input_htmx(&cs, "address.street", "Street", "/validate/street").into_string();
assert!(html.contains(r#"id="address.street-field""#), "{html}");
assert!(
html.contains(r#"hx-target="closest [data-autumn-field-wrapper]""#),
"{html}"
);
assert!(
!html.contains("hx-target=\"#address.street-field\""),
"{html}"
);
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_includes_all_form_fields() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: String::new(),
});
let html = text_input_htmx(&cs, "name", "Name", "/validate/name").into_string();
assert!(html.contains(r#"hx-include="closest form""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_drops_submit_token_param() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: String::new(),
});
let plain = text_input_htmx(&cs, "name", "Name", "/validate/name").into_string();
assert!(
plain.contains(r#"hx-params="not _submit_token""#),
"{plain}"
);
let required =
required_text_input_htmx(&cs, "name", "Name", "/validate/name").into_string();
assert!(
required.contains(r#"hx-params="not _submit_token""#),
"{required}"
);
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_with_token_field_filters_custom_field() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: String::new(),
});
let plain =
text_input_htmx_with_token_field(&cs, "name", "Name", "/validate/name", "csrf_tok")
.into_string();
assert!(plain.contains(r#"hx-params="not csrf_tok""#), "{plain}");
assert!(!plain.contains("_submit_token"), "{plain}");
let required = required_text_input_htmx_with_token_field(
&cs,
"name",
"Name",
"/validate/name",
"csrf_tok",
)
.into_string();
assert!(
required.contains(r#"hx-params="not csrf_tok""#),
"{required}"
);
assert!(!required.contains("_submit_token"), "{required}");
}
#[cfg(feature = "maud")]
#[test]
fn with_token_field_default_matches_legacy_helpers() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: String::new(),
});
assert_eq!(
text_input_htmx_with_token_field(
&cs,
"name",
"Name",
"/validate/name",
"_submit_token"
)
.into_string(),
text_input_htmx(&cs, "name", "Name", "/validate/name").into_string(),
);
assert_eq!(
required_text_input_htmx_with_token_field(
&cs,
"name",
"Name",
"/validate/name",
"_submit_token",
)
.into_string(),
required_text_input_htmx(&cs, "name", "Name", "/validate/name").into_string(),
);
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_valid_state_no_error_markup() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: "Alice".into(),
});
let html = text_input_htmx(&cs, "name", "Name", "/v").into_string();
assert!(!html.contains(r#"role="alert""#), "{html}");
assert!(html.contains(r#"aria-invalid="false""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_invalid_preserves_value_and_shows_errors() {
#[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_htmx(&cs, "name", "Name", "/v").into_string();
assert!(html.contains(r#"value="ab""#), "{html}");
assert!(html.contains("too short"), "{html}");
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_invalid_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_htmx(&cs, "email", "Email", "/v").into_string();
assert!(html.contains("email-error"), "{html}");
assert!(html.contains(r#"aria-describedby="email-error""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn checkbox_input_renders_type_checkbox() {
#[derive(serde::Serialize)]
struct F {
active: bool,
}
let cs = Changeset::new(F { active: false });
let html = checkbox_input(&cs, "active", "Active").into_string();
assert!(html.contains(r#"type="checkbox""#), "{html}");
assert!(html.contains(r#"name="active""#), "{html}");
assert!(html.contains("Active"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn checkbox_input_unchecked_when_value_false() {
#[derive(serde::Serialize)]
struct F {
active: bool,
}
let cs = Changeset::new(F { active: false });
let html = checkbox_input(&cs, "active", "Active").into_string();
assert!(!html.contains("checked"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn checkbox_input_checked_when_value_true() {
#[derive(serde::Serialize)]
struct F {
active: bool,
}
let cs = Changeset::new(F { active: true });
let html = checkbox_input(&cs, "active", "Active").into_string();
assert!(html.contains("checked"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn checkbox_input_never_emits_a_hidden_fallback() {
#[derive(serde::Serialize)]
struct F {
active: bool,
}
let cs = Changeset::new(F { active: false });
let html = checkbox_input(&cs, "active", "Active").into_string();
assert!(!html.contains(r#"type="hidden""#), "{html}");
assert_eq!(
html.matches(r#"name="active""#).count(),
1,
"checkbox_input must emit exactly one input named `active` \
(a second `name=\"active\"` sibling would duplicate the key \
on submission): {html}"
);
}
#[cfg(feature = "maud")]
#[test]
fn checkbox_input_round_trips_through_real_url_decode_when_checked() {
#[derive(serde::Serialize)]
struct F {
active: bool,
}
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(default)]
active: bool,
}
let cs = Changeset::new(F { active: true });
let html = checkbox_input(&cs, "active", "Active").into_string();
assert!(html.contains("checked"), "{html}");
let decoded: Decoded = serde_urlencoded::from_str("active=true").unwrap();
assert!(decoded.active);
}
#[cfg(feature = "maud")]
#[test]
fn checkbox_input_round_trips_through_real_url_decode_when_unchecked() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(default)]
active: bool,
}
let decoded: Decoded = serde_urlencoded::from_str("").unwrap();
assert!(!decoded.active);
}
#[cfg(feature = "maud")]
#[test]
fn checkbox_input_wrapper_div_has_stable_id() {
#[derive(serde::Serialize)]
struct F {
active: bool,
}
let cs = Changeset::new(F { active: false });
let html = checkbox_input(&cs, "active", "Active").into_string();
assert!(html.contains(r#"id="active-field""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn checkbox_input_emits_aria_invalid_and_errors() {
#[derive(serde::Serialize)]
struct F {
active: bool,
}
let mut errors = HashMap::new();
errors.insert("active".to_string(), vec!["must be true".to_string()]);
let cs = Changeset::from_errors(F { active: false }, errors);
let html = checkbox_input(&cs, "active", "Active").into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("must be true"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn number_input_renders_type_number() {
#[derive(serde::Serialize)]
struct F {
age: i32,
}
let cs = Changeset::new(F { age: 30 });
let html = number_input(&cs, "age", "Age", Some("1")).into_string();
assert!(html.contains(r#"type="number""#), "{html}");
assert!(html.contains(r#"name="age""#), "{html}");
assert!(html.contains(r#"value="30""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn number_input_renders_step_when_provided() {
#[derive(serde::Serialize)]
struct F {
price: f64,
}
let cs = Changeset::new(F { price: 9.99 });
let html = number_input(&cs, "price", "Price", Some("0.01")).into_string();
assert!(html.contains(r#"step="0.01""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn number_input_omits_step_when_none() {
#[derive(serde::Serialize)]
struct F {
age: i32,
}
let cs = Changeset::new(F { age: 30 });
let html = number_input(&cs, "age", "Age", None).into_string();
assert!(!html.contains("step="), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn number_input_emits_aria_invalid_and_errors() {
#[derive(serde::Serialize)]
struct F {
age: i32,
}
let mut errors = HashMap::new();
errors.insert("age".to_string(), vec!["must be positive".to_string()]);
let cs = Changeset::from_errors(F { age: -1 }, errors);
let html = number_input(&cs, "age", "Age", None).into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("must be positive"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn number_input_wrapper_div_has_stable_id() {
#[derive(serde::Serialize)]
struct F {
age: i32,
}
let cs = Changeset::new(F { age: 30 });
let html = number_input(&cs, "age", "Age", None).into_string();
assert!(html.contains(r#"id="age-field""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn date_input_renders_type_date() {
#[derive(serde::Serialize)]
struct F {
born_on: String,
}
let cs = Changeset::new(F {
born_on: "2024-03-15".into(),
});
let html = date_input(&cs, "born_on", "Born on").into_string();
assert!(html.contains(r#"type="date""#), "{html}");
assert!(html.contains(r#"value="2024-03-15""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn date_input_normalizes_full_timestamp_to_date_only() {
#[derive(serde::Serialize)]
struct F {
born_on: String,
}
let cs = Changeset::new(F {
born_on: "2024-03-15T10:30:00Z".into(),
});
let html = date_input(&cs, "born_on", "Born on").into_string();
assert!(html.contains(r#"value="2024-03-15""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn date_input_normalizes_space_separated_datetime_to_date_only() {
#[derive(serde::Serialize)]
struct F {
born_on: String,
}
let cs = Changeset::new(F {
born_on: "2024-03-15 10:30:00".into(),
});
let html = date_input(&cs, "born_on", "Born on").into_string();
assert!(html.contains(r#"value="2024-03-15""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn date_input_emits_aria_invalid_and_errors() {
#[derive(serde::Serialize)]
struct F {
born_on: String,
}
let mut errors = HashMap::new();
errors.insert("born_on".to_string(), vec!["required".to_string()]);
let cs = Changeset::from_errors(
F {
born_on: String::new(),
},
errors,
);
let html = date_input(&cs, "born_on", "Born on").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 datetime_input_renders_type_datetime_local() {
#[derive(serde::Serialize)]
struct F {
starts_at: String,
}
let cs = Changeset::new(F {
starts_at: "2024-03-15T10:30:00".into(),
});
let html = datetime_input(&cs, "starts_at", "Starts at").into_string();
assert!(html.contains(r#"type="datetime-local""#), "{html}");
assert!(html.contains(r#"value="2024-03-15T10:30:00""#), "{html}");
assert!(html.contains(r#"step="any""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn datetime_input_normalizes_rfc3339_with_offset_to_local_shape() {
#[derive(serde::Serialize)]
struct F {
starts_at: String,
}
let cs = Changeset::new(F {
starts_at: "2024-03-15T10:30:00Z".into(),
});
let html = datetime_input(&cs, "starts_at", "Starts at").into_string();
assert!(html.contains(r#"value="2024-03-15T10:30:00""#), "{html}");
assert!(!html.contains(r#"value="2024-03-15T10:30:00Z""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn datetime_input_normalizes_space_separated_datetime() {
#[derive(serde::Serialize)]
struct F {
starts_at: String,
}
let cs = Changeset::new(F {
starts_at: "2024-03-15 10:30:00".into(),
});
let html = datetime_input(&cs, "starts_at", "Starts at").into_string();
assert!(html.contains(r#"value="2024-03-15T10:30:00""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn datetime_input_emits_aria_invalid_and_errors() {
#[derive(serde::Serialize)]
struct F {
starts_at: String,
}
let mut errors = HashMap::new();
errors.insert("starts_at".to_string(), vec!["required".to_string()]);
let cs = Changeset::from_errors(
F {
starts_at: String::new(),
},
errors,
);
let html = datetime_input(&cs, "starts_at", "Starts at").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 datetime_input_value_round_trips_through_chronos_default_deserialize() {
#[derive(serde::Serialize)]
struct F {
starts_at: chrono::NaiveDateTime,
}
#[derive(serde::Deserialize)]
struct Decoded {
starts_at: chrono::NaiveDateTime,
}
let stored = chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(10, 30, 56)
.unwrap();
let cs = Changeset::new(F { starts_at: stored });
let html = datetime_input(&cs, "starts_at", "Starts at").into_string();
let value = html
.split("value=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.expect("value attribute present");
let body = format!("starts_at={value}");
let decoded: Decoded =
serde_urlencoded::from_str(&body).expect("pre-filled value must decode");
assert_eq!(decoded.starts_at, stored);
}
#[cfg(feature = "maud")]
#[test]
fn datetime_input_value_round_trips_for_utc_datetime_with_deserialize_helper() {
#[derive(serde::Serialize)]
struct F {
starts_at: chrono::DateTime<chrono::Utc>,
}
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(deserialize_with = "deserialize_datetime_local_utc")]
starts_at: chrono::DateTime<chrono::Utc>,
}
let stored = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(10, 30, 56)
.unwrap(),
chrono::Utc,
);
let cs = Changeset::new(F { starts_at: stored });
let html = datetime_input(&cs, "starts_at", "Starts at").into_string();
let value = html
.split("value=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.expect("value attribute present");
assert!(!value.contains('Z') && !value.contains('+'), "{value}");
let body = format!("starts_at={value}");
let decoded: Decoded =
serde_urlencoded::from_str(&body).expect("pre-filled value must decode");
assert_eq!(decoded.starts_at, stored);
}
#[cfg(feature = "maud")]
#[test]
fn deserialize_datetime_local_utc_pads_missing_seconds() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(deserialize_with = "deserialize_datetime_local_utc")]
starts_at: chrono::DateTime<chrono::Utc>,
}
let decoded: Decoded = serde_urlencoded::from_str("starts_at=2024-03-15T10:30").unwrap();
assert_eq!(
decoded.starts_at,
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(10, 30, 0)
.unwrap(),
chrono::Utc,
)
);
}
#[test]
fn deserialize_datetime_local_utc_accepts_rfc3339_with_offset() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(deserialize_with = "deserialize_datetime_local_utc")]
starts_at: chrono::DateTime<chrono::Utc>,
}
let expected = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(10, 30, 56)
.unwrap(),
chrono::Utc,
);
let decoded: Decoded =
serde_json::from_str(r#"{"starts_at":"2024-03-15T10:30:56Z"}"#).unwrap();
assert_eq!(decoded.starts_at, expected);
let decoded: Decoded =
serde_json::from_str(r#"{"starts_at":"2024-03-15T19:30:56+09:00"}"#).unwrap();
assert_eq!(decoded.starts_at, expected);
}
#[cfg(feature = "maud")]
#[test]
fn deserialize_datetime_local_utc_option_absent_key_is_none() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(default, deserialize_with = "deserialize_datetime_local_utc_option")]
starts_at: Option<chrono::DateTime<chrono::Utc>>,
}
let decoded: Decoded = serde_urlencoded::from_str("").unwrap();
assert_eq!(decoded.starts_at, None);
}
#[cfg(feature = "maud")]
#[test]
fn deserialize_datetime_local_utc_option_present_key_is_some() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(default, deserialize_with = "deserialize_datetime_local_utc_option")]
starts_at: Option<chrono::DateTime<chrono::Utc>>,
}
let decoded: Decoded = serde_urlencoded::from_str("starts_at=2024-03-15T10:30:56").unwrap();
assert_eq!(
decoded.starts_at,
Some(chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(10, 30, 56)
.unwrap(),
chrono::Utc,
))
);
}
#[test]
fn deserialize_datetime_local_local_interprets_offsetless_as_local_wall_clock() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(deserialize_with = "deserialize_datetime_local_local")]
starts_at: chrono::DateTime<chrono::Local>,
}
let expected_wall_clock = chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(12, 30, 56)
.unwrap();
let decoded: Decoded =
serde_json::from_str(r#"{"starts_at":"2024-03-15T12:30:56"}"#).unwrap();
assert_eq!(decoded.starts_at.naive_local(), expected_wall_clock);
let decoded: Decoded = serde_json::from_str(r#"{"starts_at":"2024-03-15T12:30"}"#).unwrap();
assert_eq!(
decoded.starts_at.naive_local(),
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(12, 30, 0)
.unwrap()
);
}
#[test]
fn deserialize_datetime_local_local_accepts_rfc3339_with_offset() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(deserialize_with = "deserialize_datetime_local_local")]
starts_at: chrono::DateTime<chrono::Local>,
}
let expected = chrono::DateTime::parse_from_rfc3339("2024-03-15T10:30:56Z")
.unwrap()
.with_timezone(&chrono::Local);
let decoded: Decoded =
serde_json::from_str(r#"{"starts_at":"2024-03-15T10:30:56Z"}"#).unwrap();
assert_eq!(decoded.starts_at, expected);
let decoded: Decoded =
serde_json::from_str(r#"{"starts_at":"2024-03-15T19:30:56+09:00"}"#).unwrap();
assert_eq!(decoded.starts_at, expected);
}
#[cfg(feature = "maud")]
#[test]
fn deserialize_datetime_local_local_option_absent_and_empty_are_none() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(default, deserialize_with = "deserialize_datetime_local_local_option")]
starts_at: Option<chrono::DateTime<chrono::Local>>,
}
let decoded: Decoded = serde_urlencoded::from_str("").unwrap();
assert_eq!(decoded.starts_at, None);
let decoded: Decoded = serde_urlencoded::from_str("starts_at=").unwrap();
assert_eq!(decoded.starts_at, None);
let decoded: Decoded = serde_urlencoded::from_str("starts_at=2024-03-15T12:30:56").unwrap();
assert_eq!(
decoded.starts_at.map(|dt| dt.naive_local()),
Some(
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(12, 30, 56)
.unwrap()
)
);
}
#[cfg(feature = "maud")]
#[test]
fn deserialize_naive_datetime_local_pads_missing_seconds() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(deserialize_with = "deserialize_naive_datetime_local")]
starts_at: chrono::NaiveDateTime,
}
let decoded: Decoded = serde_urlencoded::from_str("starts_at=2024-03-15T10:30").unwrap();
assert_eq!(
decoded.starts_at,
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(10, 30, 0)
.unwrap()
);
}
#[cfg(feature = "maud")]
#[test]
fn deserialize_naive_datetime_local_preserves_seconds_when_present() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(deserialize_with = "deserialize_naive_datetime_local")]
starts_at: chrono::NaiveDateTime,
}
let decoded: Decoded = serde_urlencoded::from_str("starts_at=2024-03-15T10:30:56").unwrap();
assert_eq!(
decoded.starts_at,
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(10, 30, 56)
.unwrap()
);
}
#[cfg(feature = "maud")]
#[test]
fn deserialize_naive_datetime_local_option_absent_key_is_none() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(default, deserialize_with = "deserialize_naive_datetime_local_option")]
starts_at: Option<chrono::NaiveDateTime>,
}
let decoded: Decoded = serde_urlencoded::from_str("").unwrap();
assert_eq!(decoded.starts_at, None);
}
#[cfg(feature = "maud")]
#[test]
fn deserialize_naive_datetime_local_option_present_key_is_some() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(default, deserialize_with = "deserialize_naive_datetime_local_option")]
starts_at: Option<chrono::NaiveDateTime>,
}
let decoded: Decoded = serde_urlencoded::from_str("starts_at=2024-03-15T10:30").unwrap();
assert_eq!(
decoded.starts_at,
Some(
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(10, 30, 0)
.unwrap()
)
);
}
#[cfg(feature = "maud")]
#[test]
fn datetime_input_wrapper_div_has_stable_id() {
#[derive(serde::Serialize)]
struct F {
starts_at: String,
}
let cs = Changeset::new(F {
starts_at: "2024-03-15T10:30:00".into(),
});
let html = datetime_input(&cs, "starts_at", "Starts at").into_string();
assert!(html.contains(r#"id="starts_at-field""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn select_input_renders_select_element_with_options() {
#[derive(serde::Serialize)]
struct F {
status: String,
}
let cs = Changeset::new(F {
status: "draft".into(),
});
let options = [("draft", "Draft"), ("published", "Published")];
let html = select_input(&cs, "status", "Status", &options).into_string();
assert!(html.contains("<select"), "{html}");
assert!(html.contains(r#"name="status""#), "{html}");
assert!(html.contains(r#"value="draft""#), "{html}");
assert!(html.contains("Draft"), "{html}");
assert!(html.contains(r#"value="published""#), "{html}");
assert!(html.contains("Published"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn select_input_marks_current_value_selected() {
#[derive(serde::Serialize)]
struct F {
status: String,
}
let cs = Changeset::new(F {
status: "published".into(),
});
let options = [("draft", "Draft"), ("published", "Published")];
let html = select_input(&cs, "status", "Status", &options).into_string();
assert!(html.contains(r#"value="published" selected"#), "{html}");
assert!(!html.contains(r#"value="draft" selected"#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn select_input_emits_aria_invalid_and_errors() {
#[derive(serde::Serialize)]
struct F {
status: String,
}
let mut errors = HashMap::new();
errors.insert("status".to_string(), vec!["required".to_string()]);
let cs = Changeset::from_errors(
F {
status: String::new(),
},
errors,
);
let options = [("draft", "Draft"), ("published", "Published")];
let html = select_input(&cs, "status", "Status", &options).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 select_input_wrapper_div_has_stable_id() {
#[derive(serde::Serialize)]
struct F {
status: String,
}
let cs = Changeset::new(F {
status: "draft".into(),
});
let options = [("draft", "Draft"), ("published", "Published")];
let html = select_input(&cs, "status", "Status", &options).into_string();
assert!(html.contains(r#"id="status-field""#), "{html}");
}
#[cfg(feature = "maud")]
#[derive(serde::Serialize)]
struct FormForTestModel {
title: String,
views: i32,
published: bool,
}
#[cfg(feature = "maud")]
impl FormModel for FormForTestModel {
fn form_fields() -> Vec<FormField> {
vec![
FormField::new("title", "Title", FieldControl::Text, true),
FormField::new("views", "Views", FieldControl::Number { step: None }, false),
FormField::new("published", "Published", FieldControl::Checkbox, false),
]
}
}
#[cfg(feature = "maud")]
fn blank_form_for_model() -> FormForTestModel {
FormForTestModel {
title: String::new(),
views: 0,
published: false,
}
}
#[cfg(feature = "maud")]
#[test]
fn form_for_prefills_serde_renamed_field_via_value_name() {
#[derive(serde::Serialize)]
struct RenamedModel {
#[serde(rename = "headline")]
title: String,
}
impl FormModel for RenamedModel {
fn form_fields() -> Vec<FormField> {
vec![
FormField::new("title", "Title", FieldControl::Text, true)
.with_value_name("headline"),
]
}
}
let mut errors = HashMap::new();
errors.insert("title".to_string(), vec!["too short".to_string()]);
let cs = Changeset::from_errors(
RenamedModel {
title: "Hello".into(),
},
errors,
);
let html = form_for(&cs, "/posts", "post").render().into_string();
assert!(html.contains(r#"name="title""#), "{html}");
assert!(!html.contains(r#"name="headline""#), "{html}");
assert!(html.contains(r#"value="Hello""#), "{html}");
assert!(html.contains("too short"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_renders_form_tag_csrf_and_submit() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.csrf("tok123")
.render()
.into_string();
assert!(html.contains("<form"), "{html}");
assert!(html.contains(r#"name="_csrf""#), "{html}");
assert!(html.contains(r#"value="tok123""#), "{html}");
assert!(html.contains(r#"type="submit""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_method_override_emits_hidden_input() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts/1", "PUT").render().into_string();
assert!(html.contains(r#"name="_method""#), "{html}");
assert!(html.contains(r#"value="PUT""#), "{html}");
assert!(html.contains(r#"method="post""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_renders_one_control_per_field() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post").render().into_string();
assert!(html.contains(r#"name="title""#), "{html}");
assert!(html.contains(r#"name="views""#), "{html}");
assert!(html.contains(r#"name="published""#), "{html}");
assert!(html.contains(r#"type="checkbox""#), "{html}");
assert!(html.contains(r#"type="number""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_prefills_values() {
let cs = Changeset::new(FormForTestModel {
title: "Hello".into(),
..blank_form_for_model()
});
let html = form_for(&cs, "/posts", "post").render().into_string();
assert!(html.contains(r#"value="Hello""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_renders_inline_error_adjacent() {
let mut errors = HashMap::new();
errors.insert("title".to_string(), vec!["can't be blank".to_string()]);
let cs = Changeset::from_errors(blank_form_for_model(), errors);
let html = form_for(&cs, "/posts", "post").render().into_string();
assert!(html.contains("can't be blank"), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_exclude_drops_field() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.exclude("published")
.render()
.into_string();
assert!(!html.contains(r#"name="published""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_override_field_changes_control() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.override_field(
"title",
FieldControl::Select {
options: vec![("a".into(), "A".into())],
},
)
.render()
.into_string();
assert!(html.contains("<select"), "{html}");
assert!(html.contains(r#"name="title""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_override_label() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.override_label("title", "Headline")
.render()
.into_string();
assert!(html.contains("Headline"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_append_inserts_before_submit() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.append(
maud::html! { input type="password" name="confirm" aria-label="Confirm password"; },
)
.render()
.into_string();
let append_idx = html
.find(r#"name="confirm""#)
.expect("append markup missing");
let submit_idx = html
.find(r#"type="submit""#)
.expect("submit button missing");
assert!(append_idx < submit_idx, "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_prepend_inserts_before_fields() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.csrf("tok")
.prepend(maud::html! { input type="hidden" name="_submit_token" value="st"; })
.render()
.into_string();
let csrf_idx = html.find(r#"name="_csrf""#).expect("csrf input missing");
let prepend_idx = html
.find(r#"name="_submit_token""#)
.expect("prepend markup missing");
let first_field_idx = html.find(r#"name="title""#).expect("derived field missing");
assert!(
csrf_idx < prepend_idx && prepend_idx < first_field_idx,
"prepended token must sit after csrf and before the first derived field: {html}"
);
}
#[cfg(feature = "maud")]
#[test]
fn form_for_required_date_field_renders_required_attribute() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.override_field("title", FieldControl::Date)
.render()
.into_string();
assert!(html.contains(r#"type="date""#), "{html}");
assert!(html.contains(r#"aria-required="true""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_file_field_sets_multipart() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.override_field("title", FieldControl::File)
.render()
.into_string();
assert!(html.contains(r#"enctype="multipart/form-data""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_multipart_put_emits_method_override_and_csrf_together() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts/1", "PUT")
.csrf("tok456")
.multipart()
.render()
.into_string();
assert!(html.contains(r#"enctype="multipart/form-data""#), "{html}");
assert!(html.contains(r#"method="post""#), "{html}");
assert!(html.contains(r#"name="_method""#), "{html}");
assert!(html.contains(r#"value="PUT""#), "{html}");
assert!(html.contains(r#"name="_csrf""#), "{html}");
assert!(html.contains(r#"value="tok456""#), "{html}");
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts/1", "PUT")
.csrf("tok456")
.render()
.into_string();
assert!(!html.contains("enctype"), "{html}");
assert!(html.contains(r#"name="_method""#), "{html}");
assert!(html.contains(r#"name="_csrf""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_file_field_renders_errors_aria_and_required() {
let mut errors = HashMap::new();
errors.insert("title".to_string(), vec!["must be a PDF".to_string()]);
let cs = Changeset::from_errors(blank_form_for_model(), errors);
let html = form_for(&cs, "/posts", "post")
.override_field("title", FieldControl::File)
.render()
.into_string();
assert!(html.contains(r#"type="file""#), "{html}");
assert!(html.contains(r#"id="title-field""#), "{html}");
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"aria-describedby="title-error""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("must be a PDF"), "{html}");
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("required"), "{html}");
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.exclude("title")
.override_field("published", FieldControl::File)
.render()
.into_string();
assert!(html.contains(r#"id="published-field""#), "{html}");
assert!(html.contains(r#"aria-invalid="false""#), "{html}");
assert!(!html.contains(r#"aria-required="true""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_routes_controls_through_a11y_primitives() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.exclude("views")
.exclude("published")
.override_field("title", FieldControl::Textarea)
.render()
.into_string();
assert!(html.contains("<textarea"), "{html}");
assert!(html.contains(r#"aria-invalid="false""#), "{html}");
assert!(
!html.contains(r#"aria-describedby="""#),
"error-free textarea must not emit an empty aria-describedby: {html}"
);
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.exclude("views")
.exclude("title")
.render()
.into_string();
assert!(html.contains(r#"type="checkbox""#), "{html}");
assert!(html.contains(r#"<label for="published""#), "{html}");
let input_idx = html
.find(r#"type="checkbox""#)
.expect("checkbox input missing");
let label_idx = html
.find(r#"<label for="published""#)
.expect("checkbox label missing");
assert!(
input_idx < label_idx,
"checkbox input must precede its label (conventional layout): {html}"
);
assert!(
!html.contains(r#"aria-describedby="""#),
"error-free checkbox must not emit an empty aria-describedby: {html}"
);
}
#[cfg(feature = "maud")]
#[test]
fn form_for_duplicate_overrides_last_wins() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.override_field("title", FieldControl::Date)
.override_field("title", FieldControl::Textarea)
.override_label("title", "First")
.override_label("title", "Second")
.render()
.into_string();
assert!(html.contains("<textarea"), "{html}");
assert!(!html.contains(r#"type="date""#), "{html}");
assert!(html.contains("Second"), "{html}");
assert!(!html.contains("First"), "{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);
}
#[derive(serde::Deserialize, validator::Validate)]
struct OptionalNumericForm {
#[validate(length(min = 3))]
name: String,
age: Option<i32>,
}
#[tokio::test]
async fn blank_optional_numeric_field_decodes_as_none_not_400() {
async fn handler(form: ChangesetForm<OptionalNumericForm>) -> String {
format!("valid={} age={:?}", form.is_valid(), form.data().age)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&age="))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
assert_body(resp, "valid=true age=None").await;
}
#[tokio::test]
async fn filled_optional_numeric_field_still_decodes() {
async fn handler(form: ChangesetForm<OptionalNumericForm>) -> String {
format!("valid={} age={:?}", form.is_valid(), form.data().age)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&age=30"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
assert_body(resp, "valid=true age=Some(30)").await;
}
#[tokio::test]
async fn garbage_numeric_field_still_fails_decode() {
async fn handler(_form: ChangesetForm<OptionalNumericForm>) -> String {
"unreachable".to_string()
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&age=not-a-number"))
.await
.unwrap();
assert_ne!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn blank_required_string_survives_alongside_blank_optional_numeric() {
async fn handler(form: ChangesetForm<OptionalNumericForm>) -> String {
format!(
"valid={} name={:?} age={:?}",
form.is_valid(),
form.data().name,
form.data().age
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=&age="))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
assert_body(resp, "valid=false name=\"\" age=None").await;
}
#[derive(serde::Deserialize, validator::Validate)]
struct CheckboxForm {
#[serde(default)]
published: bool,
}
#[tokio::test]
async fn blank_defaulted_checkbox_field_decodes_to_its_default() {
async fn handler(form: ChangesetForm<CheckboxForm>) -> String {
format!("published={}", form.data().published)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "published="))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
assert_body(resp, "published=false").await;
}
#[derive(serde::Deserialize, validator::Validate)]
struct RequiredBoolForm {
#[allow(
dead_code,
reason = "decode is expected to fail before this is ever read"
)]
accepted_terms: bool,
}
#[tokio::test]
async fn blank_required_field_without_default_still_fails_to_decode() {
async fn handler(_form: ChangesetForm<RequiredBoolForm>) -> String {
"unreachable".to_string()
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "accepted_terms="))
.await
.unwrap();
assert_ne!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn oversized_body_is_rejected_not_fully_buffered() {
async fn handler(_form: ChangesetForm<TestForm>) -> String {
"unreachable".to_string()
}
let oversized = format!("name={}", "a".repeat(3 * 1024 * 1024));
let req = axum::http::Request::builder()
.method("POST")
.uri("/test")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from(oversized))
.unwrap();
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn wrong_content_type_is_rejected_not_decoded_anyway() {
async fn handler(_form: ChangesetForm<TestForm>) -> String {
"unreachable".to_string()
}
let req = axum::http::Request::builder()
.method("POST")
.uri("/test")
.header("Content-Type", "text/plain")
.body(Body::from("name=Alice"))
.unwrap();
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_eq!(
resp.status(),
axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE
);
}
#[tokio::test]
async fn missing_content_type_is_rejected_not_decoded_anyway() {
async fn handler(_form: ChangesetForm<TestForm>) -> String {
"unreachable".to_string()
}
let req = axum::http::Request::builder()
.method("POST")
.uri("/test")
.body(Body::from("name=Alice"))
.unwrap();
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_eq!(
resp.status(),
axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE
);
}
#[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_blank_optional_numeric_field_decodes_as_none_not_400() {
async fn handler(form: ChangesetForm<OptionalNumericForm>) -> String {
format!("valid={} age={:?}", form.is_valid(), form.data().age)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(multipart_req_multi(
"/test",
&[("name", "Alice"), ("age", "")],
))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
assert_body(resp, "valid=true age=None").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;
}
#[derive(serde::Deserialize, validator::Validate, serde::Serialize)]
struct InlineTestForm {
#[validate(length(min = 3, message = "Name must be at least 3 characters"))]
name: String,
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn inline_valid_field_returns_field_partial_without_errors() {
async fn handler(form: ChangesetForm<InlineTestForm>) -> maud::Markup {
text_input_htmx(&form.changeset, "name", "Name", "/validate/name")
}
let resp = Router::new()
.route("/validate/name", post(handler))
.oneshot(urlencoded_req("/validate/name", "name=Alice"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let body = body_text(resp).await;
assert!(body.contains(r#"aria-invalid="false""#), "{body}");
assert!(!body.contains(r#"role="alert""#), "{body}");
assert!(body.contains(r#"value="Alice""#), "{body}");
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn inline_invalid_field_returns_field_partial_with_errors() {
async fn handler(form: ChangesetForm<InlineTestForm>) -> maud::Markup {
text_input_htmx(&form.changeset, "name", "Name", "/validate/name")
}
let resp = Router::new()
.route("/validate/name", post(handler))
.oneshot(urlencoded_req("/validate/name", "name=ab"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let body = body_text(resp).await;
assert!(body.contains(r#"aria-invalid="true""#), "{body}");
assert!(body.contains(r#"role="alert""#), "{body}");
assert!(
body.contains("Name must be at least 3 characters"),
"{body}"
);
assert!(body.contains(r#"value="ab""#), "{body}");
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn inline_invalid_field_partial_is_htmx_swappable() {
async fn handler(form: ChangesetForm<InlineTestForm>) -> maud::Markup {
text_input_htmx(&form.changeset, "name", "Name", "/validate/name")
}
let resp = Router::new()
.route("/validate/name", post(handler))
.oneshot(urlencoded_req("/validate/name", "name=ab"))
.await
.unwrap();
let body = body_text(resp).await;
assert!(body.contains(r#"id="name-field""#), "{body}");
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn full_form_submit_invalid_returns_422() {
async fn handler(form: ChangesetForm<InlineTestForm>) -> impl IntoResponse {
match form.into_valid() {
Ok(_) => axum::http::StatusCode::OK.into_response(),
Err(form) => (
axum::http::StatusCode::UNPROCESSABLE_ENTITY,
text_input_htmx(&form.changeset, "name", "Name", "/validate/name"),
)
.into_response(),
}
}
let resp = Router::new()
.route("/submit", post(handler))
.oneshot(urlencoded_req("/submit", "name=ab"))
.await
.unwrap();
assert_eq!(
resp.status(),
axum::http::StatusCode::UNPROCESSABLE_ENTITY,
"full-form invalid submit must return 422"
);
let body = body_text(resp).await;
assert!(
body.contains("Name must be at least 3 characters"),
"{body}"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn full_form_submit_valid_returns_200() {
async fn handler(form: ChangesetForm<InlineTestForm>) -> impl IntoResponse {
match form.into_valid() {
Ok(_) => axum::http::StatusCode::OK.into_response(),
Err(form) => (
axum::http::StatusCode::UNPROCESSABLE_ENTITY,
text_input_htmx(&form.changeset, "name", "Name", "/validate/name"),
)
.into_response(),
}
}
let resp = Router::new()
.route("/submit", post(handler))
.oneshot(urlencoded_req("/submit", "name=Alice"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
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> {
multipart_req_multi(uri, &[(field, value)])
}
#[cfg(feature = "multipart")]
fn multipart_req_multi(uri: &str, fields: &[(&str, &str)]) -> axum::http::Request<Body> {
use std::fmt::Write as _;
let boundary = "----FormBoundary7MA4YWxkTrZu0gW";
let mut body = String::new();
for (field, value) in fields {
let _ = write!(
body,
"--{boundary}\r\n\
Content-Disposition: form-data; name=\"{field}\"\r\n\r\n\
{value}\r\n"
);
}
let _ = write!(body, "--{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);
}
}
}