#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
#[cfg(feature = "maud")]
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::OnceLock;
use axum::extract::{FromRequest, Request};
use axum::response::IntoResponse;
use serde::Serialize;
pub struct Changeset<T> {
data: T,
errors: HashMap<String, Vec<String>>,
field_value_cache: OnceLock<Box<serde_json::Value>>,
}
#[allow(
clippy::missing_fields_in_debug,
reason = "field_value_cache is deliberately omitted — see its doc comment"
)]
impl<T: std::fmt::Debug> std::fmt::Debug for Changeset<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Changeset")
.field("data", &self.data)
.field("errors", &self.errors)
.finish()
}
}
impl<T> Changeset<T> {
pub fn new(data: T) -> Self {
Self {
data,
errors: HashMap::new(),
field_value_cache: OnceLock::new(),
}
}
pub const fn from_errors(data: T, errors: HashMap<String, Vec<String>>) -> Self {
Self {
data,
errors,
field_value_cache: OnceLock::new(),
}
}
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
}
pub fn add_error(&mut self, field: impl Into<String>, message: impl Into<String>) {
self.errors
.entry(field.into())
.or_default()
.push(message.into());
}
}
impl<T: Serialize> Changeset<T> {
pub fn field_value(&self, field: &str) -> Option<String> {
let json = self.field_value_cache.get_or_init(|| {
Box::new(serde_json::to_value(&self.data).unwrap_or(serde_json::Value::Null))
});
json_scalar_to_string(json.get(field)?)
}
pub fn field_values(&self, field: &str) -> Vec<String> {
let json = self.field_value_cache.get_or_init(|| {
Box::new(serde_json::to_value(&self.data).unwrap_or(serde_json::Value::Null))
});
match json.get(field) {
Some(serde_json::Value::Array(items)) => {
items.iter().filter_map(json_scalar_to_string).collect()
}
Some(value) => json_scalar_to_string(value).into_iter().collect(),
None => Vec::new(),
}
}
}
fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
match value {
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 const NUL_CHARACTER_FIELD_ERROR: &str = "Cannot contain the NUL character (0x00)";
pub const NUL_CHARACTER_MESSAGE_KEY: &str = "common.error.nul_character";
#[cfg(feature = "i18n")]
pub(crate) async fn nul_field_message<S: Send + Sync>(
req: Request,
state: &S,
) -> (Request, String) {
use axum::extract::FromRequestParts as _;
let Some(bundle) = req
.extensions()
.get::<std::sync::Arc<crate::i18n::Bundle>>()
.cloned()
else {
return (req, NUL_CHARACTER_FIELD_ERROR.to_owned());
};
let (mut parts, body) = req.into_parts();
let message = match crate::i18n::Locale::from_request_parts(&mut parts, state).await {
Ok(locale) => bundle.lookup(locale.tag(), NUL_CHARACTER_MESSAGE_KEY),
Err(never) => match never {},
};
(
Request::from_parts(parts, body),
message.unwrap_or_else(|| NUL_CHARACTER_FIELD_ERROR.to_owned()),
)
}
#[cfg(not(feature = "i18n"))]
#[allow(clippy::unused_async)]
pub(crate) async fn nul_field_message<S: Send + Sync>(
req: Request,
_state: &S,
) -> (Request, String) {
(req, NUL_CHARACTER_FIELD_ERROR.to_owned())
}
const PLUMBING_FIELDS: &[&str] = &["_method", "_csrf", "_submit_token"];
pub(crate) fn is_plumbing_field(name: &str) -> bool {
PLUMBING_FIELDS.contains(&name)
}
pub(crate) fn strip_nul_from_pairs(pairs: &mut [(String, String)]) -> Vec<String> {
let mut offenders: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for (key, value) in pairs {
if value.as_bytes().contains(&0) {
*value = crate::normalize::strip_nul(value);
if !offenders.contains(key.as_str()) {
offenders.insert(key.clone());
}
}
}
offenders.into_iter().collect()
}
pub trait IntoChangeset: Sized {
fn into_changeset(self) -> Changeset<Self>;
fn into_changeset_with(
self,
_resolve: impl Fn(&str, &str) -> Option<String>,
) -> Changeset<Self> {
self.into_changeset()
}
}
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)),
}
}
fn into_changeset_with(
self,
resolve: impl Fn(&str, &str) -> Option<String>,
) -> Changeset<Self> {
match validator::Validate::validate(&self) {
Ok(()) => Changeset::new(self),
Err(errors) => {
Changeset::from_errors(self, validation_errors_to_map_with(&errors, &resolve))
}
}
}
}
pub struct ChangesetForm<T> {
pub changeset: Changeset<T>,
pub(crate) csrf_token: Option<String>,
pub(crate) csrf_field: String,
#[allow(
clippy::box_collection,
reason = "a `Box<Vec>` is one pointer wide; a `Vec` or `Box<[T]>` pushes `into_valid`'s `Err` over `result_large_err`"
)]
pub(crate) files: Option<Box<Vec<FormFile>>>,
}
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(),
files: None,
}
}
#[must_use]
pub fn without_csrf(data: T) -> Self {
Self {
changeset: Changeset::new(data),
csrf_token: None,
csrf_field: "_csrf".to_owned(),
files: None,
}
}
#[must_use]
pub fn from_changeset(changeset: Changeset<T>) -> Self {
Self {
changeset,
csrf_token: None,
csrf_field: "_csrf".to_owned(),
files: None,
}
}
#[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 file(&self, field: &str) -> Option<&FormFile> {
self.all_files().iter().find(|file| file.field == field)
}
pub fn files<'a>(&'a self, field: &'a str) -> impl Iterator<Item = &'a FormFile> {
self.all_files()
.iter()
.filter(move |file| file.field == field)
}
pub fn take_files(&mut self) -> Vec<FormFile> {
self.files.take().map(|files| *files).unwrap_or_default()
}
#[must_use]
pub fn with_files(mut self, files: Vec<FormFile>) -> Self {
self.files = (!files.is_empty()).then(|| Box::new(files));
self
}
fn all_files(&self) -> &[FormFile] {
self.files.as_deref().map_or(&[], Vec::as_slice)
}
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 rich_text_area(&self, field: &str, label: &str) -> maud::Markup {
rich_text_area(&self.changeset, field, label)
}
pub fn rich_text_area_with_labels(
&self,
field: &str,
label: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
rich_text_area_with_labels(&self.changeset, field, label, labels)
}
pub fn rich_text_area_htmx(&self, field: &str, label: &str, preview_url: &str) -> maud::Markup {
rich_text_area_htmx(&self.changeset, field, label, preview_url)
}
pub fn rich_text_area_htmx_with_labels(
&self,
field: &str,
label: &str,
preview_url: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
rich_text_area_htmx_with_labels(&self.changeset, field, label, preview_url, labels)
}
pub fn rich_text_area_htmx_with_token_field(
&self,
field: &str,
label: &str,
preview_url: &str,
token_field: &str,
) -> maud::Markup {
rich_text_area_htmx_with_token_field(
&self.changeset,
field,
label,
preview_url,
token_field,
)
}
pub fn rich_text_area_htmx_with_token_field_with_labels(
&self,
field: &str,
label: &str,
preview_url: &str,
token_field: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
rich_text_area_htmx_with_token_field_with_labels(
&self.changeset,
field,
label,
preview_url,
token_field,
labels,
)
}
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 submit_field = req
.extensions()
.get::<crate::security::SubmitFormField>()
.map_or_else(|| "_submit_token".to_owned(), |f| f.0.clone());
let (req, nul_message) = nul_field_message(req, state).await;
let (data, nul_fields, files) = decode_form_body::<T, S>(req, state).await?;
let mut changeset = data.into_changeset();
for field in nul_fields {
if field == csrf_field
|| field == submit_field
|| PLUMBING_FIELDS.contains(&field.as_str())
{
continue;
}
changeset.add_error(field, nul_message.as_str());
}
Ok(Self {
changeset,
csrf_token,
csrf_field,
files: (!files.is_empty()).then(|| Box::new(files)),
})
}
}
#[allow(clippy::result_large_err)]
async fn decode_form_body<T, S>(
req: Request,
state: &S,
) -> Result<(T, Vec<String>, Vec<FormFile>), 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 crate::media_type::is_media_type(&content_type, "multipart/form-data") {
return decode_multipart(req, state).await;
}
if !crate::media_type::is_media_type(&content_type, "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)?;
let mut pairs = parse_urlencoded_pairs(&bytes);
let nul_fields = strip_nul_from_pairs(&mut pairs);
let data = decode_pairs_dropping_blank_optional_fields::<T>(pairs)
.map_err(|e| (e.status(), e.to_string()).into_response())?;
Ok((data, nul_fields, Vec::new()))
}
pub(crate) fn decode_urlencoded_dropping_blank_optional_fields<T: serde::de::DeserializeOwned>(
bytes: &[u8],
) -> Result<T, FormDecodeError> {
decode_pairs_dropping_blank_optional_fields(parse_urlencoded_pairs(bytes))
}
pub(crate) fn parse_urlencoded_pairs(bytes: &[u8]) -> Vec<(String, String)> {
url::form_urlencoded::parse(bytes)
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect()
}
pub(crate) fn decode_pairs_dropping_blank_optional_fields<T: serde::de::DeserializeOwned>(
mut pairs: Vec<(String, String)>,
) -> Result<T, FormDecodeError> {
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_html_form::Deserializer::from_bytes(encoded.as_bytes());
match serde_path_to_error::deserialize(deserializer) {
Ok(data) => return Ok(data),
Err(err) => match next_retry(&pairs, err.path()) {
Retry::DropPair(pos) => {
pairs.remove(pos);
}
Retry::DropBlanksOf(field) => {
pairs.retain(|(k, v)| !(*k == field && v.is_empty()));
}
Retry::Repeated(field) => return Err(FormDecodeError::repeated(field)),
Retry::Stop => return Err(FormDecodeError::invalid(err)),
},
}
}
}
enum Retry {
DropPair(usize),
DropBlanksOf(String),
Repeated(String),
Stop,
}
fn next_retry(pairs: &[(String, String)], path: &serde_path_to_error::Path) -> Retry {
let mut segments = path.iter();
let Some(serde_path_to_error::Segment::Map { key: field }) = segments.next() else {
return Retry::Stop;
};
let mut same_key = pairs.iter().enumerate().filter(|(_, (k, _))| k == field);
match (segments.next(), segments.next()) {
(None, _) => {
if pairs.iter().filter(|(k, _)| k == field).nth(1).is_some() {
return Retry::Repeated(field.clone());
}
same_key
.find(|(_, (_, v))| v.is_empty())
.map_or(Retry::Stop, |(i, _)| Retry::DropPair(i))
}
(Some(serde_path_to_error::Segment::Seq { index }), None) => {
if same_key.nth(*index).is_some_and(|(_, (_, v))| v.is_empty()) {
Retry::DropBlanksOf(field.clone())
} else {
Retry::Stop
}
}
_ => Retry::Stop,
}
}
pub fn from_urlencoded<T: serde::de::DeserializeOwned>(body: &[u8]) -> Result<T, FormDecodeError> {
decode_urlencoded_dropping_blank_optional_fields(body)
}
const MAX_REPORTED_FIELD_CHARS: usize = 64;
#[derive(Debug, Clone)]
pub struct FormDecodeError {
field: Option<String>,
kind: FormDecodeErrorKind,
}
#[derive(Debug, Clone)]
enum FormDecodeErrorKind {
Repeated,
Invalid(serde_path_to_error::Error<serde_html_form::de::Error>),
}
impl FormDecodeError {
const fn repeated(field: String) -> Self {
Self {
field: Some(field),
kind: FormDecodeErrorKind::Repeated,
}
}
fn invalid(err: serde_path_to_error::Error<serde_html_form::de::Error>) -> Self {
let field = err.path().iter().find_map(|segment| match segment {
serde_path_to_error::Segment::Map { key } => Some(key.clone()),
_ => None,
});
Self {
field,
kind: FormDecodeErrorKind::Invalid(err),
}
}
#[must_use]
pub const fn is_repeated_field(&self) -> bool {
matches!(self.kind, FormDecodeErrorKind::Repeated)
}
#[must_use]
pub fn field(&self) -> Option<&str> {
self.field.as_deref()
}
#[must_use]
pub const fn status(&self) -> http::StatusCode {
match self.kind {
FormDecodeErrorKind::Repeated => http::StatusCode::UNPROCESSABLE_ENTITY,
FormDecodeErrorKind::Invalid(_) => http::StatusCode::BAD_REQUEST,
}
}
pub(crate) const fn invalid_error(
&self,
) -> Option<&serde_path_to_error::Error<serde_html_form::de::Error>> {
match &self.kind {
FormDecodeErrorKind::Invalid(err) => Some(err),
FormDecodeErrorKind::Repeated => None,
}
}
}
impl std::fmt::Display for FormDecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.kind {
FormDecodeErrorKind::Repeated => {
let field = self.field.as_deref().unwrap_or_default();
let mut shown: String = field
.chars()
.filter(|c| is_printable_key_char(*c))
.take(MAX_REPORTED_FIELD_CHARS)
.collect();
if field
.chars()
.filter(|c| is_printable_key_char(*c))
.nth(MAX_REPORTED_FIELD_CHARS)
.is_some()
{
shown.push('…');
}
write!(
f,
"form field `{shown}` takes one value, but the form sent it more than once"
)
}
FormDecodeErrorKind::Invalid(err) => err.fmt(f),
}
}
}
impl std::error::Error for FormDecodeError {}
fn is_printable_key_char(c: char) -> bool {
!c.is_control()
&& !matches!(
c,
'\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}'
)
}
#[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")]
#[allow(clippy::result_large_err)]
async fn decode_multipart<T, S>(
req: Request,
state: &S,
) -> Result<(T, Vec<String>, Vec<FormFile>), axum::response::Response>
where
T: serde::de::DeserializeOwned,
S: Send + Sync,
{
let mut multipart = crate::extract::Multipart::from_request_with_route_limit(req, state)
.await
.map_err(IntoResponse::into_response)?;
let mut pairs: Vec<(String, String)> = Vec::new();
let mut files: Vec<FormFile> = Vec::new();
while let Some(field) = multipart
.next_field()
.await
.map_err(IntoResponse::into_response)?
{
let Some(name) = field.name().map(str::to_owned) else {
continue;
};
if field.is_empty_optional_input() {
continue;
}
let file_name = field.file_name().map(str::to_owned);
let content_type = field.content_type().map(str::to_owned);
let sniffed_content_type = field.sniffed_content_type().map(str::to_owned);
let mut bytes = field
.bytes_limited()
.await
.map_err(IntoResponse::into_response)?;
if let Some(file_name) = file_name {
if file_name.is_empty() && bytes.is_empty() {
continue;
}
bytes.shrink_to_fit();
files.push(FormFile {
field: name,
file_name,
content_type,
sniffed_content_type,
bytes,
});
} else {
let value = String::from_utf8(bytes).map_err(|_| {
(
axum::http::StatusCode::BAD_REQUEST,
"a multipart text field is not valid UTF-8",
)
.into_response()
})?;
pairs.push((name, value));
}
}
let nul_fields = strip_nul_from_pairs(&mut pairs);
let data = decode_pairs_dropping_blank_optional_fields::<T>(pairs)
.map_err(|e| (e.status(), e.to_string()).into_response())?;
Ok((data, nul_fields, files))
}
#[derive(Clone)]
pub struct FormFile {
field: String,
file_name: String,
content_type: Option<String>,
sniffed_content_type: Option<String>,
bytes: Vec<u8>,
}
impl FormFile {
#[must_use]
pub fn new(
field: impl Into<String>,
file_name: impl Into<String>,
content_type: Option<String>,
bytes: Vec<u8>,
) -> Self {
Self {
field: field.into(),
file_name: file_name.into(),
content_type,
sniffed_content_type: None,
bytes,
}
}
#[must_use]
pub fn field(&self) -> &str {
&self.field
}
#[must_use]
pub fn file_name(&self) -> &str {
&self.file_name
}
#[must_use]
pub fn content_type(&self) -> Option<&str> {
self.content_type.as_deref()
}
#[must_use]
pub fn sniffed_content_type(&self) -> Option<&str> {
self.sniffed_content_type.as_deref()
}
#[must_use]
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
#[must_use]
pub fn into_bytes(self) -> Vec<u8> {
self.bytes
}
#[must_use]
pub const fn len(&self) -> usize {
self.bytes.len()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.bytes.is_empty()
}
}
impl std::fmt::Debug for FormFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FormFile")
.field("field", &self.field)
.field("file_name", &self.file_name)
.field("content_type", &self.content_type)
.field("sniffed_content_type", &self.sniffed_content_type)
.field("len", &self.bytes.len())
.finish()
}
}
pub(crate) fn validation_errors_to_map(
errors: &validator::ValidationErrors,
) -> HashMap<String, Vec<String>> {
let mut map = HashMap::new();
collect_errors(errors, "", &mut map, &|_, _| None);
map
}
pub(crate) fn validation_errors_to_map_with(
errors: &validator::ValidationErrors,
resolve: &dyn Fn(&str, &str) -> Option<String>,
) -> HashMap<String, Vec<String>> {
let mut map = HashMap::new();
collect_errors(errors, "", &mut map, resolve);
map
}
fn collect_errors(
errors: &validator::ValidationErrors,
prefix: &str,
map: &mut HashMap<String, Vec<String>>,
resolve: &dyn Fn(&str, &str) -> Option<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(
|| {
resolve(&key, &e.code)
.unwrap_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, resolve);
}
validator::ValidationErrorsKind::List(list) => {
for (idx, nested) in list {
let indexed_key = format!("{key}[{idx}]");
collect_errors(nested, &indexed_key, map, resolve);
}
}
}
}
}
#[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")]
#[allow(
clippy::string_slice,
reason = "every slice index below comes from a position() match on a single-byte ASCII \
value (&, <, >, \"), which can never land inside a multi-byte UTF-8 sequence — \
always a char boundary. Proven for arbitrary input by the \
fast_escape_matches_naive_reference proptest."
)]
#[allow(
clippy::arithmetic_side_effects,
reason = "`i + 1` where `i` is a byte index already `< input.len()` (from `bytes.iter().\
enumerate()`), and `input.len() <= isize::MAX` is a `str` invariant — cannot \
overflow `usize`."
)]
fn write_escaped(input: &str, out: &mut String) {
let bytes = input.as_bytes();
let mut start = 0;
for (i, &b) in bytes.iter().enumerate() {
let replacement = match b {
b'&' => "&",
b'<' => "<",
b'>' => ">",
b'"' => """,
_ => continue,
};
out.push_str(&input[start..i]);
out.push_str(replacement);
start = i + 1;
}
out.push_str(&input[start..]);
}
#[cfg(feature = "maud")]
fn fast_escape(input: &str) -> Cow<'_, str> {
if !input
.as_bytes()
.iter()
.any(|b| matches!(b, b'&' | b'<' | b'>' | b'"'))
{
return Cow::Borrowed(input);
}
let mut out = String::with_capacity(input.len());
write_escaped(input, &mut out);
Cow::Owned(out)
}
#[cfg(feature = "maud")]
fn concat_suffix(base: &str, suffix: &str) -> String {
let mut s = String::with_capacity(base.len().saturating_add(suffix.len()));
s.push_str(base);
s.push_str(suffix);
s
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FieldOptions<'a> {
hint: Option<Cow<'a, str>>,
required: bool,
disabled: bool,
readonly: bool,
placeholder: Option<Cow<'a, str>>,
autocomplete: Option<Cow<'a, str>>,
inputmode: Option<Cow<'a, str>>,
pattern: Option<Cow<'a, str>>,
min: Option<Cow<'a, str>>,
max: Option<Cow<'a, str>>,
extra: Vec<(&'static str, Cow<'a, str>, Cow<'a, str>)>,
}
#[cfg(feature = "maud")]
impl<'a> FieldOptions<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
hint: None,
required: false,
disabled: false,
readonly: false,
placeholder: None,
autocomplete: None,
inputmode: None,
pattern: None,
min: None,
max: None,
extra: Vec::new(),
}
}
#[must_use]
pub fn hint(mut self, text: impl Into<Cow<'a, str>>) -> Self {
self.hint = Some(text.into());
self
}
#[must_use]
pub const fn required(mut self) -> Self {
self.required = true;
self
}
#[must_use]
pub const fn disabled(mut self) -> Self {
self.disabled = true;
self
}
#[must_use]
pub const fn readonly(mut self) -> Self {
self.readonly = true;
self
}
#[must_use]
pub fn placeholder(mut self, text: impl Into<Cow<'a, str>>) -> Self {
self.placeholder = Some(text.into());
self
}
#[must_use]
pub fn autocomplete(mut self, token: impl Into<Cow<'a, str>>) -> Self {
self.autocomplete = Some(token.into());
self
}
#[must_use]
pub fn inputmode(mut self, mode: impl Into<Cow<'a, str>>) -> Self {
self.inputmode = Some(mode.into());
self
}
#[must_use]
pub fn pattern(mut self, regex: impl Into<Cow<'a, str>>) -> Self {
self.pattern = Some(regex.into());
self
}
#[must_use]
pub fn min(mut self, min: impl Into<Cow<'a, str>>) -> Self {
self.min = Some(min.into());
self
}
#[must_use]
pub fn max(mut self, max: impl Into<Cow<'a, str>>) -> Self {
self.max = Some(max.into());
self
}
#[must_use]
pub fn data(self, name: impl Into<Cow<'a, str>>, value: impl Into<Cow<'a, str>>) -> Self {
let name = name.into();
if name.starts_with("autumn-") || !is_valid_extra_name(&name, false) {
return self;
}
self.with_extra("data-", name, value.into())
}
#[must_use]
pub fn hx(self, name: impl Into<Cow<'a, str>>, value: impl Into<Cow<'a, str>>) -> Self {
let name = name.into();
if !is_valid_extra_name(&name, true) {
return self;
}
self.with_extra("hx-", name, value.into())
}
pub(crate) const fn is_required(&self) -> bool {
self.required
}
pub(crate) const fn optional(mut self) -> Self {
self.required = false;
self
}
fn with_extra(mut self, prefix: &'static str, name: Cow<'a, str>, value: Cow<'a, str>) -> Self {
if let Some(slot) = self
.extra
.iter_mut()
.find(|(p, n, _)| *p == prefix && *n == name)
{
slot.2 = value;
} else {
self.extra.push((prefix, name, value));
}
self
}
}
#[cfg(feature = "maud")]
fn is_valid_extra_name(name: &str, allow_colon: bool) -> bool {
!name.is_empty()
&& name.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || (allow_colon && b == b':')
})
}
#[cfg(feature = "maud")]
const HTMX_OWNED: &[&str] = &["post", "trigger", "target", "swap", "include", "params"];
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FieldKind {
Text,
Password,
Textarea,
Checkbox,
Number,
Date,
DateTime,
Select,
File,
}
#[cfg(feature = "maud")]
impl FieldKind {
const fn input_type(self) -> Option<&'static str> {
match self {
Self::Text => Some("text"),
Self::Password => Some("password"),
Self::Checkbox => Some("checkbox"),
Self::Number => Some("number"),
Self::Date => Some("date"),
Self::DateTime => Some("datetime-local"),
Self::File => Some("file"),
Self::Textarea | Self::Select => None,
}
}
const fn takes_text_entry(self) -> bool {
matches!(
self,
Self::Text | Self::Password | Self::Textarea | Self::Number
)
}
const fn takes_pattern(self) -> bool {
matches!(self, Self::Text | Self::Password)
}
const fn takes_range(self) -> bool {
matches!(self, Self::Number | Self::Date | Self::DateTime)
}
const fn takes_readonly(self) -> bool {
!matches!(self, Self::Checkbox | Self::Select | Self::File)
}
const fn takes_autocomplete(self) -> bool {
!matches!(self, Self::Checkbox | Self::File)
}
}
#[cfg(feature = "maud")]
#[derive(Clone, Copy)]
pub(crate) enum SelectChoices<'a> {
None,
Borrowed(&'a [(&'a str, &'a str)]),
Owned(&'a [(String, String)]),
}
#[cfg(feature = "maud")]
pub(crate) struct FieldSpec<'a> {
pub(crate) kind: FieldKind,
pub(crate) field: &'a str,
pub(crate) name: Option<&'a str>,
pub(crate) label: &'a str,
pub(crate) value: Option<&'a str>,
pub(crate) current: &'a str,
pub(crate) checked: bool,
pub(crate) step: Option<&'a str>,
pub(crate) choices: SelectChoices<'a>,
pub(crate) htmx: Option<(&'a str, &'a str)>,
}
#[cfg(feature = "maud")]
impl<'a> FieldSpec<'a> {
pub(crate) const fn new(kind: FieldKind, field: &'a str, label: &'a str) -> Self {
Self {
kind,
field,
name: None,
label,
value: None,
current: "",
checked: false,
step: None,
choices: SelectChoices::None,
htmx: None,
}
}
}
#[cfg(feature = "maud")]
fn push_attr(out: &mut String, name: &str, value: &str) {
out.push(' ');
out.push_str(name);
out.push_str("=\"");
write_escaped(value, out);
out.push('"');
}
#[cfg(feature = "maud")]
const REQUIRED_MARKER: &str = r#"<span class="autumn-field__required" aria-hidden="true">*</span>"#;
#[cfg(feature = "maud")]
fn push_label(out: &mut String, field_html: &str, label: &str, required: bool) {
out.push_str(r#"<label for=""#);
out.push_str(field_html);
out.push_str(r#"" class="autumn-field__label">"#);
write_escaped(label, out);
if required {
out.push_str(REQUIRED_MARKER);
}
out.push_str("</label>");
}
#[cfg(feature = "maud")]
fn push_option_attrs(
out: &mut String,
kind: FieldKind,
opts: &FieldOptions<'_>,
reserved: &[&str],
) {
let typed = [
(
"placeholder",
opts.placeholder.as_deref(),
kind.takes_text_entry(),
),
(
"autocomplete",
opts.autocomplete.as_deref(),
kind.takes_autocomplete(),
),
(
"inputmode",
opts.inputmode.as_deref(),
kind.takes_text_entry(),
),
("pattern", opts.pattern.as_deref(), kind.takes_pattern()),
("min", opts.min.as_deref(), kind.takes_range()),
("max", opts.max.as_deref(), kind.takes_range()),
];
for (name, value, accepted) in typed {
if accepted && let Some(value) = value {
push_attr(out, name, value);
}
}
if opts.disabled {
out.push_str(" disabled");
}
if opts.readonly && kind.takes_readonly() {
out.push_str(" readonly");
}
for (prefix, name, value) in &opts.extra {
if *prefix == "hx-" && reserved.contains(&name.as_ref()) {
continue;
}
out.push(' ');
out.push_str(prefix);
out.push_str(name);
out.push_str("=\"");
write_escaped(value, out);
out.push('"');
}
}
#[cfg(feature = "maud")]
fn push_hint(out: &mut String, field_html: &str, hint: &str) {
out.push_str(r#"<p id=""#);
out.push_str(field_html);
out.push_str(r#"-hint" class="autumn-field__hint">"#);
write_escaped(hint, out);
out.push_str("</p>");
}
#[cfg(feature = "maud")]
fn push_errors(out: &mut String, field_html: &str, errors: &[String]) {
out.push_str(r#"<div id=""#);
out.push_str(field_html);
out.push_str(r#"-error" role="alert" class="autumn-field__errors">"#);
for error in errors {
out.push_str(r#"<p class="autumn-field__error">"#);
write_escaped(error, out);
out.push_str("</p>");
}
out.push_str("</div>");
}
#[cfg(feature = "maud")]
fn described_by(field_html: &str, has_hint: bool, has_errors: bool) -> Option<String> {
match (has_hint, has_errors) {
(false, false) => None,
(true, false) => Some(concat_suffix(field_html, "-hint")),
(false, true) => Some(concat_suffix(field_html, "-error")),
(true, true) => {
let mut ids =
String::with_capacity(field_html.len().saturating_mul(2).saturating_add(12));
ids.push_str(field_html);
ids.push_str("-hint ");
ids.push_str(field_html);
ids.push_str("-error");
Some(ids)
}
}
}
#[cfg(feature = "maud")]
#[allow(
clippy::too_many_lines,
reason = "one linear writer for the whole field; splitting it hides the attribute order"
)]
pub(crate) fn render_field(
spec: &FieldSpec<'_>,
errors: &[String],
opts: &FieldOptions<'_>,
) -> maud::Markup {
let kind = spec.kind;
let has_errors = !errors.is_empty();
let hint = opts.hint.as_deref();
let field_html = fast_escape(spec.field);
let field_html = field_html.as_ref();
let describedby = described_by(field_html, hint.is_some(), has_errors);
let errors_len: usize = errors.iter().map(|e| e.len().saturating_add(40)).sum();
let mut out = String::with_capacity(
360usize
.saturating_add(field_html.len().saturating_mul(6))
.saturating_add(spec.label.len())
.saturating_add(spec.value.map_or(0, str::len))
.saturating_add(hint.map_or(0, str::len))
.saturating_add(errors_len),
);
out.push_str(r#"<div id=""#);
out.push_str(field_html);
out.push_str(r#"-field" class=""#);
out.push_str(if kind == FieldKind::Checkbox {
"autumn-field autumn-field--checkbox"
} else {
"autumn-field"
});
out.push('"');
if spec.htmx.is_some() {
out.push_str(r#" data-autumn-field-wrapper=""#);
out.push_str(field_html);
out.push('"');
}
out.push('>');
if kind != FieldKind::Checkbox {
push_label(&mut out, field_html, spec.label, opts.required);
if let Some(hint) = hint {
push_hint(&mut out, field_html, hint);
}
}
let tag = match kind {
FieldKind::Textarea => "textarea",
FieldKind::Select => "select",
_ => "input",
};
out.push('<');
out.push_str(tag);
if let Some(input_type) = kind.input_type() {
push_attr(&mut out, "type", input_type);
}
out.push_str(r#" id=""#);
out.push_str(field_html);
out.push('"');
if let Some(name) = spec.name {
push_attr(&mut out, "name", name);
} else {
out.push_str(r#" name=""#);
out.push_str(field_html);
out.push('"');
}
if kind == FieldKind::Checkbox {
out.push_str(r#" value="true""#);
if spec.checked {
out.push_str(" checked");
}
} else if kind.input_type().is_some()
&& let Some(value) = spec.value
{
push_attr(&mut out, "value", value);
}
if let Some(step) = spec.step {
push_attr(&mut out, "step", step);
}
if opts.required {
out.push_str(r#" required aria-required="true""#);
}
out.push_str(if has_errors {
r#" class="autumn-field__input autumn-field__input--invalid" aria-invalid="true""#
} else {
r#" class="autumn-field__input" aria-invalid="false""#
});
if let Some(ids) = &describedby {
out.push_str(r#" aria-describedby=""#);
out.push_str(ids);
out.push('"');
}
let reserved: &[&str] = if let Some((validate_url, token_field)) = spec.htmx {
push_attr(&mut out, "hx-post", validate_url);
out.push_str(
r#" hx-trigger="change" hx-target="closest [data-autumn-field-wrapper]" hx-swap="outerHTML" hx-include="closest form" hx-params="not "#,
);
write_escaped(token_field, &mut out);
out.push('"');
HTMX_OWNED
} else {
&[]
};
push_option_attrs(&mut out, kind, opts, reserved);
out.push('>');
match kind {
FieldKind::Textarea => {
write_escaped(spec.value.unwrap_or_default(), &mut out);
out.push_str("</textarea>");
}
FieldKind::Select => {
let mut push_option = |value: &str, label: &str| {
out.push_str("<option");
push_attr(&mut out, "value", value);
if value == spec.current {
out.push_str(" selected");
}
out.push('>');
write_escaped(label, &mut out);
out.push_str("</option>");
};
match spec.choices {
SelectChoices::None => {}
SelectChoices::Borrowed(choices) => {
for (value, label) in choices {
push_option(value, label);
}
}
SelectChoices::Owned(choices) => {
for (value, label) in choices {
push_option(value, label);
}
}
}
out.push_str("</select>");
}
_ => {}
}
if kind == FieldKind::Checkbox {
push_label(&mut out, field_html, spec.label, opts.required);
if let Some(hint) = hint {
push_hint(&mut out, field_html, hint);
}
}
if has_errors {
push_errors(&mut out, field_html, errors);
}
out.push_str("</div>");
maud::PreEscaped(out)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn text_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
text_input_with(changeset, field, label, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn text_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = changeset.field_value(field).unwrap_or_default();
let spec = FieldSpec {
value: Some(&value),
..FieldSpec::new(FieldKind::Text, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[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 {
text_input_htmx_with(
changeset,
field,
label,
validate_url,
token_field,
&FieldOptions::new(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn text_input_htmx_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
validate_url: &str,
token_field: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = changeset.field_value(field).unwrap_or_default();
let spec = FieldSpec {
value: Some(&value),
htmx: Some((validate_url, token_field)),
..FieldSpec::new(FieldKind::Text, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[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 {
text_input_htmx_with(
changeset,
field,
label,
validate_url,
token_field,
&FieldOptions::new().required(),
)
}
#[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 {
password_input_with(changeset, field, label, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_password_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
password_input_with(changeset, field, label, &FieldOptions::new().required())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn password_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let spec = FieldSpec::new(FieldKind::Password, field, label);
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn textarea_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
textarea_input_with(changeset, field, label, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_textarea_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
textarea_input_with(changeset, field, label, &FieldOptions::new().required())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn textarea_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = changeset.field_value(field).unwrap_or_default();
let spec = FieldSpec {
value: Some(&value),
..FieldSpec::new(FieldKind::Textarea, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[must_use]
pub fn field_from_urlencoded(body: &[u8], field: &str) -> Option<String> {
url::form_urlencoded::parse(body)
.find(|(key, _)| key.as_ref() == field)
.map(|(_, value)| value.into_owned())
}
#[cfg(feature = "maud")]
const RICH_TEXT_HINT: &str = "Markdown supported. HTML is not allowed and is shown as plain text.";
#[cfg(feature = "maud")]
const RICH_TEXT_TOOLBAR: &[(&str, &str)] = &[
("Bold", "**bold**"),
("Italic", "_italic_"),
("Link", "[text](url)"),
("Code", "`code`"),
("List", "- item"),
("Heading", "# Heading"),
("Quote", "> quote"),
];
#[cfg(feature = "maud")]
#[derive(Clone, Copy)]
pub struct RichTextLabels<'a> {
toolbar_group: &'a str,
controls: &'a [(&'a str, &'a str)],
hint: &'a str,
preview_heading: &'a str,
}
#[cfg(feature = "maud")]
impl<'a> RichTextLabels<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
toolbar_group: "Markdown formatting",
controls: RICH_TEXT_TOOLBAR,
hint: RICH_TEXT_HINT,
preview_heading: "Preview",
}
}
#[must_use]
pub const fn toolbar_group(mut self, label: &'a str) -> Self {
self.toolbar_group = label;
self
}
#[must_use]
pub const fn controls(mut self, controls: &'a [(&'a str, &'a str)]) -> Self {
self.controls = controls;
self
}
#[must_use]
pub const fn hint(mut self, hint: &'a str) -> Self {
self.hint = hint;
self
}
#[must_use]
pub const fn preview_heading(mut self, heading: &'a str) -> Self {
self.preview_heading = heading;
self
}
}
#[cfg(feature = "maud")]
impl Default for RichTextLabels<'static> {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn rich_text_area<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
rich_text_area_inner(
changeset,
field,
label,
None,
&RichTextLabels::new(),
&FieldOptions::new(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn rich_text_area_with_labels<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
rich_text_area_inner(changeset, field, label, None, labels, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn rich_text_area_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
labels: &RichTextLabels<'_>,
options: &FieldOptions<'_>,
) -> maud::Markup {
rich_text_area_inner(changeset, field, label, None, labels, options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn rich_text_area_htmx_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
token_field: &str,
labels: &RichTextLabels<'_>,
options: &FieldOptions<'_>,
) -> maud::Markup {
rich_text_area_inner(
changeset,
field,
label,
Some((preview_url, token_field)),
labels,
options,
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn rich_text_area_htmx<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
) -> maud::Markup {
rich_text_area_htmx_with_token_field(
changeset,
field,
label,
preview_url,
DEFAULT_SUBMIT_TOKEN_FIELD,
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn rich_text_area_htmx_with_labels<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
rich_text_area_htmx_with_token_field_with_labels(
changeset,
field,
label,
preview_url,
DEFAULT_SUBMIT_TOKEN_FIELD,
labels,
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn rich_text_area_htmx_with_token_field<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
token_field: &str,
) -> maud::Markup {
rich_text_area_inner(
changeset,
field,
label,
Some((preview_url, token_field)),
&RichTextLabels::new(),
&FieldOptions::new(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn rich_text_area_htmx_with_token_field_with_labels<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
token_field: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
rich_text_area_inner(
changeset,
field,
label,
Some((preview_url, token_field)),
labels,
&FieldOptions::new(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_rich_text_area<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
rich_text_area_inner(
changeset,
field,
label,
None,
&RichTextLabels::new(),
&FieldOptions::new().required(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_rich_text_area_with_labels<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
rich_text_area_inner(
changeset,
field,
label,
None,
labels,
&FieldOptions::new().required(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_rich_text_area_htmx<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
) -> maud::Markup {
required_rich_text_area_htmx_with_token_field(
changeset,
field,
label,
preview_url,
DEFAULT_SUBMIT_TOKEN_FIELD,
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_rich_text_area_htmx_with_labels<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
required_rich_text_area_htmx_with_token_field_with_labels(
changeset,
field,
label,
preview_url,
DEFAULT_SUBMIT_TOKEN_FIELD,
labels,
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_rich_text_area_htmx_with_token_field<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
token_field: &str,
) -> maud::Markup {
rich_text_area_inner(
changeset,
field,
label,
Some((preview_url, token_field)),
&RichTextLabels::new(),
&FieldOptions::new().required(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_rich_text_area_htmx_with_token_field_with_labels<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
token_field: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
rich_text_area_inner(
changeset,
field,
label,
Some((preview_url, token_field)),
labels,
&FieldOptions::new().required(),
)
}
#[cfg(feature = "maud")]
fn rich_text_area_inner<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview: Option<(&str, &str)>,
labels: &RichTextLabels<'_>,
options: &FieldOptions<'_>,
) -> maud::Markup {
let required = options.required;
let errors = changeset.errors_for(field);
let has_errors = !errors.is_empty();
let value = changeset.field_value(field).unwrap_or_default();
let error_id = has_errors.then(|| format!("{field}-error"));
let hint_id = format!("{field}-hint");
let wrapper_id = format!("{field}-field");
let preview_id = format!("{field}-preview");
let described_by = if has_errors {
format!("{hint_id} {}", error_id.as_deref().unwrap_or_default())
} else {
hint_id.clone()
};
let hint = options.hint.as_deref().unwrap_or(labels.hint);
let mut editor = String::with_capacity(320usize.saturating_add(value.len()));
editor.push_str("<textarea");
push_attr(&mut editor, "id", field);
push_attr(&mut editor, "name", field);
editor.push_str(r#" rows="12""#);
editor.push_str(if has_errors {
r#" class="autumn-field__input autumn-rich-text__editor autumn-field__input--invalid" aria-invalid="true""#
} else {
r#" class="autumn-field__input autumn-rich-text__editor" aria-invalid="false""#
});
push_attr(&mut editor, "aria-describedby", &described_by);
if required {
editor.push_str(r#" required aria-required="true""#);
}
let reserved: &[&str] = if let Some((url, token_field)) = preview {
push_attr(&mut editor, "hx-post", url);
editor.push_str(r##" hx-trigger="keyup changed delay:400ms, change" hx-target="#"##);
write_escaped(&preview_id, &mut editor);
editor.push_str(r#"" hx-swap="innerHTML" hx-include="closest form" hx-params="not "#);
write_escaped(token_field, &mut editor);
editor.push('"');
HTMX_OWNED
} else {
&[]
};
push_option_attrs(&mut editor, FieldKind::Textarea, options, reserved);
editor.push('>');
write_escaped(&value, &mut editor);
editor.push_str("</textarea>");
maud::html! {
div id=(wrapper_id) class="autumn-field autumn-rich-text" data-autumn-field-wrapper=(field) {
label for=(field) class="autumn-field__label" {
(label)
@if required { (maud::PreEscaped(REQUIRED_MARKER)) }
}
div class="autumn-rich-text__toolbar" role="group" aria-label=(labels.toolbar_group) {
@for (control, syntax) in labels.controls {
span class="autumn-rich-text__toolbar-item" {
span class="autumn-rich-text__toolbar-label" { (control) }
code class="autumn-rich-text__toolbar-syntax" { (syntax) }
}
}
}
(maud::PreEscaped(editor))
p id=(hint_id) class="autumn-rich-text__hint" { (hint) }
@if has_errors {
div id=(error_id.as_deref().unwrap_or_default()) role="alert" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
@if preview.is_some() {
div class="autumn-rich-text__preview-wrapper" {
span class="autumn-rich-text__preview-label" id=(format!("{field}-preview-label")) { (labels.preview_heading) }
div
id=(preview_id)
class="autumn-rich-text__preview"
role="region"
aria-labelledby=(format!("{field}-preview-label"))
{ (rich_text_preview(&value)) }
}
}
}
}
}
#[cfg(all(feature = "maud", feature = "markdown"))]
fn rich_text_preview(value: &str) -> maud::Markup {
crate::markdown::render_user_content(value)
}
#[cfg(all(feature = "maud", not(feature = "markdown")))]
fn rich_text_preview(_value: &str) -> maud::Markup {
maud::html! {}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_text_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
text_input_with(changeset, field, label, &FieldOptions::new().required())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn checkbox_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
checkbox_input_with(changeset, field, label, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_checkbox_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
checkbox_input_with(changeset, field, label, &FieldOptions::new().required())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn checkbox_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let spec = FieldSpec {
checked: changeset.field_value(field).as_deref() == Some("true"),
..FieldSpec::new(FieldKind::Checkbox, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn number_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
step: Option<&str>,
) -> maud::Markup {
number_input_with(changeset, field, label, step, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn number_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
step: Option<&str>,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = changeset.field_value(field).unwrap_or_default();
let spec = FieldSpec {
value: Some(&value),
step,
..FieldSpec::new(FieldKind::Number, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_number_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
step: Option<&str>,
) -> maud::Markup {
number_input_with(
changeset,
field,
label,
step,
&FieldOptions::new().required(),
)
}
#[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 {
date_input_with(changeset, field, label, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn date_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = normalize_date_value(&changeset.field_value(field).unwrap_or_default());
let spec = FieldSpec {
value: Some(&value),
..FieldSpec::new(FieldKind::Date, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_date_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
date_input_with(changeset, field, label, &FieldOptions::new().required())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn datetime_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
datetime_input_with(changeset, field, label, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn datetime_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = normalize_datetime_local_value(&changeset.field_value(field).unwrap_or_default());
let spec = FieldSpec {
value: Some(&value),
step: Some("any"),
..FieldSpec::new(FieldKind::DateTime, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_datetime_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
datetime_input_with(changeset, field, label, &FieldOptions::new().required())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn select_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &[(&str, &str)],
) -> maud::Markup {
select_input_with(changeset, field, label, options, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn select_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
choices: &[(&str, &str)],
options: &FieldOptions<'_>,
) -> maud::Markup {
let current = changeset.field_value(field).unwrap_or_default();
let spec = FieldSpec {
current: ¤t,
choices: SelectChoices::Borrowed(choices),
..FieldSpec::new(FieldKind::Select, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_select_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &[(&str, &str)],
) -> maud::Markup {
select_input_with(
changeset,
field,
label,
options,
&FieldOptions::new().required(),
)
}
#[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(),
field_options: 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)>,
field_options: Vec<(String, FieldOptions<'a>)>,
prepended: Vec<maud::Markup>,
appended: Vec<maud::Markup>,
submit_label: String,
force_multipart: bool,
}
#[cfg(feature = "maud")]
impl<'a, T: Serialize + FormModel> FormFor<'a, 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 field_options(mut self, field: impl Into<String>, options: FieldOptions<'a>) -> Self {
self.field_options.push((field.into(), options));
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,
field_options,
prepended,
appended,
submit_label,
force_multipart,
} = self;
let no_options = FieldOptions::new();
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,
field_options
.iter()
.rev()
.find(|(name, _)| *name == field.name)
.map_or(&no_options, |(_, options)| options),
))
}
@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 render_form_field<T: Serialize>(
changeset: &Changeset<T>,
field: &FormField,
options: &FieldOptions<'_>,
) -> 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, options);
}
render_form_control(changeset, field, options)
}
#[cfg(feature = "maud")]
fn render_form_control<T: Serialize>(
changeset: &Changeset<T>,
field: &FormField,
options: &FieldOptions<'_>,
) -> maud::Markup {
let required_by_model = field.required
&& !matches!(
field.control,
FieldControl::Checkbox | FieldControl::Password
);
let merged;
let options = if required_by_model && !options.required {
merged = options.clone().required();
&merged
} else {
options
};
let (name, label) = (field.name.as_str(), field.label.as_str());
match &field.control {
FieldControl::Text => text_input_with(changeset, name, label, options),
FieldControl::Textarea => textarea_input_with(changeset, name, label, options),
FieldControl::Password => password_input_with(changeset, name, label, options),
FieldControl::Number { step } => {
number_input_with(changeset, name, label, step.as_deref(), options)
}
FieldControl::Checkbox => checkbox_input_with(changeset, name, label, options),
FieldControl::Date => date_input_with(changeset, name, label, options),
FieldControl::DateTime => datetime_input_with(changeset, name, label, options),
FieldControl::Select { options: choices } => {
let current = changeset.field_value(name).unwrap_or_default();
let spec = FieldSpec {
current: ¤t,
choices: SelectChoices::Owned(choices),
..FieldSpec::new(FieldKind::Select, name, label)
};
render_field(&spec, changeset.errors_for(name), options)
}
FieldControl::File => {
let spec = FieldSpec::new(FieldKind::File, name, label);
render_field(&spec, changeset.errors_for(name), options)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fast_escape_no_special_chars_borrows() {
let input = "ART-1042";
match fast_escape(input) {
std::borrow::Cow::Borrowed(s) => assert_eq!(s, input),
std::borrow::Cow::Owned(_) => panic!("expected a borrow, no allocation needed"),
}
}
#[test]
fn fast_escape_empty_string_borrows() {
assert!(matches!(fast_escape(""), std::borrow::Cow::Borrowed("")));
}
#[test]
fn fast_escape_escapes_all_four_chars() {
assert_eq!(fast_escape(r#"&<>""#), "&<>"");
}
#[test]
fn fast_escape_leading_trailing_and_adjacent_specials() {
assert_eq!(fast_escape("&start"), "&start");
assert_eq!(fast_escape("end&"), "end&");
assert_eq!(fast_escape("a&&b"), "a&&b");
assert_eq!(
fast_escape("<script>alert('x')</script>"),
"<script>alert('x')</script>"
);
}
#[test]
fn fast_escape_preserves_multibyte_utf8() {
assert_eq!(fast_escape("café <3"), "café <3");
assert_eq!(fast_escape("日本語"), "日本語");
}
fn naive_escape_reference(input: &str) -> String {
let mut out = Vec::with_capacity(input.len());
for b in input.bytes() {
match b {
b'&' => out.extend_from_slice(b"&"),
b'<' => out.extend_from_slice(b"<"),
b'>' => out.extend_from_slice(b">"),
b'"' => out.extend_from_slice(b"""),
_ => out.push(b),
}
}
String::from_utf8(out).expect("escaping ASCII-only replacements preserves valid UTF-8")
}
proptest::proptest! {
#[test]
fn fast_escape_matches_naive_reference(s in ".*") {
proptest::prop_assert_eq!(fast_escape(&s).into_owned(), naive_escape_reference(&s));
}
}
mod repeated_keys {
use super::super::*;
use std::collections::HashSet;
#[derive(Debug, serde::Deserialize)]
struct Numbers {
#[serde(default)]
a: Vec<u32>,
}
#[test]
fn repeated_key_decodes_into_vec() {
let form: Numbers = from_urlencoded(b"a=1&a=2").unwrap();
assert_eq!(form.a, vec![1, 2]);
}
#[test]
fn no_key_decodes_into_empty_vec() {
let form: Numbers = from_urlencoded(b"").unwrap();
assert!(form.a.is_empty());
}
#[test]
fn single_key_decodes_into_one_element_vec() {
let form: Numbers = from_urlencoded(b"a=7").unwrap();
assert_eq!(form.a, vec![7]);
}
#[test]
fn repeated_key_decodes_into_hash_set() {
#[derive(serde::Deserialize)]
struct Tags {
#[serde(default)]
tag: HashSet<String>,
}
let form: Tags = from_urlencoded(b"tag=a&tag=b&tag=a").unwrap();
assert_eq!(form.tag, HashSet::from(["a".to_owned(), "b".to_owned()]));
}
#[test]
fn option_vec_is_none_when_absent_and_some_when_present() {
#[derive(serde::Deserialize)]
struct F {
ids: Option<Vec<i64>>,
}
let absent: F = from_urlencoded(b"").unwrap();
assert_eq!(absent.ids, None);
let one: F = from_urlencoded(b"ids=3").unwrap();
assert_eq!(one.ids, Some(vec![3]));
let two: F = from_urlencoded(b"ids=3&ids=4").unwrap();
assert_eq!(two.ids, Some(vec![3, 4]));
}
#[test]
fn percent_encoded_values_decode_into_vec() {
#[derive(serde::Deserialize)]
struct F {
#[serde(default)]
q: Vec<String>,
}
let form: F = from_urlencoded(b"q=a%20b&q=c+d&q=%26%3D&q=%C3%A9").unwrap();
assert_eq!(form.q, vec!["a b", "c d", "&=", "é"]);
}
#[test]
fn repeated_key_into_scalar_is_a_repeated_field_error() {
#[derive(Debug, serde::Deserialize)]
struct F {
#[allow(dead_code)]
name: String,
}
let err = from_urlencoded::<F>(b"name=a&name=b").unwrap_err();
assert!(err.is_repeated_field(), "{err}");
assert_eq!(err.field(), Some("name"));
assert_eq!(err.status(), http::StatusCode::UNPROCESSABLE_ENTITY);
assert!(err.to_string().contains("`name`"), "{err}");
}
#[test]
fn repeated_blank_and_filled_scalar_is_still_repeated() {
#[derive(Debug, serde::Deserialize)]
struct F {
#[allow(dead_code)]
count: Option<i32>,
}
let err = from_urlencoded::<F>(b"count=&count=5").unwrap_err();
assert!(err.is_repeated_field(), "{err}");
}
#[test]
fn malformed_element_is_a_400_not_a_repeat() {
let err = from_urlencoded::<Numbers>(b"a=1&a=x").unwrap_err();
assert!(!err.is_repeated_field(), "{err}");
assert_eq!(err.status(), http::StatusCode::BAD_REQUEST);
assert_eq!(err.field(), Some("a"));
}
#[test]
fn blank_element_of_a_typed_vec_is_dropped() {
let form: Numbers = from_urlencoded(b"a=&a=2&a=").unwrap();
assert_eq!(form.a, vec![2]);
}
#[test]
fn blank_element_of_a_string_vec_is_kept() {
#[derive(serde::Deserialize)]
struct F {
#[serde(default)]
q: Vec<String>,
}
let form: F = from_urlencoded(b"q=&q=x").unwrap();
assert_eq!(form.q, vec!["", "x"]);
}
#[test]
fn repeated_field_error_bounds_a_hostile_key() {
let key = format!("k{}\u{7}", "x".repeat(500));
let body = format!("{key}=1&{key}=2");
let err = from_urlencoded::<std::collections::HashMap<String, String>>(body.as_bytes())
.unwrap_err();
assert!(err.is_repeated_field(), "{err}");
let text = err.to_string();
assert!(text.len() < 300, "{text}");
assert!(!text.contains('\u{7}'), "{text}");
}
#[test]
fn single_value_fields_decode_as_before() {
#[derive(Debug, PartialEq, serde::Deserialize)]
struct F {
name: String,
age: Option<i32>,
note: Option<String>,
#[serde(default)]
active: bool,
}
let form: F = from_urlencoded(b"name=Ann&age=¬e=&active=true").unwrap();
assert_eq!(
form,
F {
name: "Ann".to_owned(),
age: None,
note: Some(String::new()),
active: true,
}
);
}
#[test]
fn repeated_field_error_converts_to_a_422_autumn_error() {
#[derive(Debug, serde::Deserialize)]
struct F {
#[allow(dead_code)]
name: String,
}
let err: crate::AutumnError =
from_urlencoded::<F>(b"name=a&name=b").unwrap_err().into();
assert_eq!(err.status(), http::StatusCode::UNPROCESSABLE_ENTITY);
}
#[test]
fn many_blank_elements_decode_in_one_retry() {
let mut body = "ids=&".repeat(50_000);
body.push_str("ids=1");
let started = std::time::Instant::now();
let form: Numbers = from_urlencoded(body.replace("ids", "a").as_bytes()).unwrap();
assert_eq!(form.a, vec![1]);
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"{:?}",
started.elapsed()
);
}
#[test]
fn invalid_body_converts_to_a_400_autumn_error() {
let err: crate::AutumnError = from_urlencoded::<Numbers>(b"a=x").unwrap_err().into();
assert_eq!(err.status(), http::StatusCode::BAD_REQUEST);
}
#[test]
fn brackets_are_part_of_the_key_unless_aliased() {
#[derive(serde::Deserialize)]
struct Plain {
#[serde(default)]
ids: Vec<i64>,
}
#[derive(Debug, serde::Deserialize)]
struct Aliased {
#[serde(default, alias = "ids[]")]
ids: Vec<i64>,
}
let plain: Plain = from_urlencoded(b"ids[]=1").unwrap();
assert!(plain.ids.is_empty());
let aliased: Aliased = from_urlencoded(b"ids[]=1&ids[]=2").unwrap();
assert_eq!(aliased.ids, vec![1, 2]);
let mixed = from_urlencoded::<Aliased>(b"ids=1&ids[]=2").unwrap_err();
assert_eq!(mixed.status(), http::StatusCode::BAD_REQUEST);
}
#[test]
fn bool_accepts_on() {
#[derive(serde::Deserialize)]
struct F {
#[serde(default)]
active: bool,
}
let form: F = from_urlencoded(b"active=on").unwrap();
assert!(form.active);
}
#[test]
fn form_file_accessors_and_debug_hide_the_bytes() {
let file = FormFile::new(
"avatar",
"me.png",
Some("image/png".to_owned()),
b"secret".to_vec(),
);
assert_eq!(file.field(), "avatar");
assert_eq!(file.file_name(), "me.png");
assert_eq!(file.content_type(), Some("image/png"));
assert_eq!(file.sniffed_content_type(), None);
assert_eq!(file.len(), 6);
assert!(!file.is_empty());
let debug = format!("{file:?}");
assert!(debug.contains("len: 6"), "{debug}");
assert!(!debug.contains("secret"), "{debug}");
assert_eq!(file.into_bytes(), b"secret");
}
#[test]
fn with_files_attaches_files_to_a_form() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let mut form = ChangesetForm::without_csrf(F {
name: "x".to_owned(),
})
.with_files(vec![FormFile::new("a", "a.txt", None, Vec::new())]);
assert!(form.file("a").is_some_and(FormFile::is_empty));
assert_eq!(form.take_files().len(), 1);
assert!(form.file("a").is_none());
}
#[test]
fn field_values_lists_every_element_of_a_sequence_field() {
#[derive(serde::Serialize)]
struct F {
tags: Vec<String>,
ids: Vec<i64>,
name: String,
}
let cs = Changeset::new(F {
tags: vec!["a".to_owned(), "b".to_owned()],
ids: vec![1, 2],
name: "x".to_owned(),
});
assert_eq!(cs.field_values("tags"), vec!["a", "b"]);
assert_eq!(cs.field_values("ids"), vec!["1", "2"]);
assert_eq!(cs.field_values("name"), vec!["x"]);
assert!(cs.field_values("missing").is_empty());
}
#[test]
fn field_values_of_a_none_field_is_empty() {
#[derive(serde::Serialize)]
struct G {
tags: Option<Vec<String>>,
}
assert!(
Changeset::new(G { tags: None })
.field_values("tags")
.is_empty()
);
}
proptest::proptest! {
#[test]
fn vec_of_strings_round_trips(
values in proptest::collection::vec(proptest::prelude::any::<String>(), 0..8)
) {
#[derive(serde::Deserialize)]
struct F {
#[serde(default)]
v: Vec<String>,
}
let body = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(values.iter().map(|value| ("v", value.as_str())))
.finish();
let form: F = from_urlencoded(body.as_bytes()).unwrap();
proptest::prop_assert_eq!(form.v, values);
}
}
}
#[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_with_resolves_a_translated_message_for_an_unmessaged_code() {
#[derive(validator::Validate)]
struct F {
#[validate(email)]
email: String,
}
let cs = F {
email: "not-an-email".into(),
}
.into_changeset_with(|field, code| {
if field == "email" && code == "email" {
Some("not a valid address".to_string())
} else {
None
}
});
assert!(!cs.is_valid());
assert_eq!(cs.errors_for("email"), ["not a valid address".to_string()]);
}
#[test]
fn into_changeset_with_falls_back_to_the_default_message_when_resolve_returns_none() {
#[derive(validator::Validate)]
struct F {
#[validate(email)]
email: String,
}
let without_resolver = F {
email: "not-an-email".into(),
}
.into_changeset();
let with_noop_resolver = F {
email: "not-an-email".into(),
}
.into_changeset_with(|_, _| None);
assert_eq!(
with_noop_resolver.errors_for("email"),
without_resolver.errors_for("email")
);
}
#[test]
fn into_changeset_with_never_overrides_an_explicit_message() {
#[derive(validator::Validate)]
struct F {
#[validate(length(min = 5, message = "too short"))]
name: String,
}
let cs = F { name: "ab".into() }.into_changeset_with(|_, _| {
Some("the resolver's message, which must never win".to_string())
});
assert_eq!(cs.errors_for("name"), ["too short".to_string()]);
}
#[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());
}
#[derive(validator::Validate)]
struct NestedContact {
#[validate(email)]
email: String,
}
#[derive(validator::Validate)]
struct PersonWithContact {
#[validate(nested)]
contact: NestedContact,
}
#[test]
fn into_changeset_with_resolver_sees_the_dotted_nested_key() {
use std::cell::RefCell;
let seen_keys: RefCell<Vec<String>> = RefCell::new(Vec::new());
let cs = PersonWithContact {
contact: NestedContact {
email: "not-an-email".into(),
},
}
.into_changeset_with(|field, code| {
seen_keys.borrow_mut().push(field.to_string());
(field == "contact.email" && code == "email")
.then(|| "not a valid address".to_string())
});
assert!(!cs.is_valid());
assert_eq!(
cs.errors_for("contact.email"),
["not a valid address".to_string()]
);
assert_eq!(seen_keys.into_inner(), vec!["contact.email".to_string()]);
}
}
#[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(),
files: None,
};
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(),
files: None,
};
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(),
files: None,
};
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(),
files: None,
};
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}");
}
#[test]
fn strict_form_decode_fails_on_a_partially_filled_form() {
#[derive(serde::Deserialize)]
struct PostForm {
#[allow(dead_code)]
body: String,
#[allow(dead_code)]
count: i64,
}
let partial = b"body=hello+%2A%2Aworld%2A%2A&count=";
assert!(
serde_urlencoded::from_bytes::<PostForm>(partial).is_err(),
"whole-form decode must fail here — that is the bug being fixed"
);
assert_eq!(
field_from_urlencoded(partial, "body").as_deref(),
Some("hello **world**")
);
}
#[test]
fn field_from_urlencoded_decodes_percent_and_plus_escapes() {
let body = b"title=hi&body=a+%2B+b+%26+%3Cc%3E&other=x";
assert_eq!(
field_from_urlencoded(body, "body").as_deref(),
Some("a + b & <c>")
);
assert_eq!(field_from_urlencoded(body, "title").as_deref(), Some("hi"));
assert_eq!(field_from_urlencoded(body, "absent"), None);
}
#[test]
fn field_from_urlencoded_handles_empty_and_repeated_keys() {
assert_eq!(
field_from_urlencoded(b"body=&x=1", "body").as_deref(),
Some("")
);
assert_eq!(
field_from_urlencoded(b"body=first&body=second", "body").as_deref(),
Some("first")
);
assert_eq!(field_from_urlencoded(b"", "body"), None);
assert_eq!(field_from_urlencoded(b"body_extra=no", "body"), None);
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_with_labels_overrides_the_hint() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let labels = RichTextLabels::new().hint("Se admite Markdown.");
let html = rich_text_area_with_labels(&cs, "body", "Body", &labels).into_string();
assert!(html.contains("Se admite Markdown."), "{html}");
assert!(!html.contains("Markdown supported"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_with_labels_overrides_the_toolbar_group_label() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let labels = RichTextLabels::new().toolbar_group("Formato Markdown");
let html = rich_text_area_with_labels(&cs, "body", "Body", &labels).into_string();
assert!(html.contains(r#"aria-label="Formato Markdown""#), "{html}");
assert!(!html.contains("Markdown formatting"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_with_labels_overrides_the_controls() {
const CUSTOM_CONTROLS: &[(&str, &str)] = &[("Negrita", "**negrita**")];
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let labels = RichTextLabels::new().controls(CUSTOM_CONTROLS);
let html = rich_text_area_with_labels(&cs, "body", "Body", &labels).into_string();
assert!(html.contains("Negrita"), "{html}");
assert!(html.contains("**negrita**"), "{html}");
assert!(!html.contains(">Bold<"), "{html}");
assert!(!html.contains("**bold**"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_htmx_with_labels_overrides_the_preview_heading() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let labels = RichTextLabels::new().preview_heading("Vista previa");
let html =
rich_text_area_htmx_with_labels(&cs, "body", "Body", "/p", &labels).into_string();
assert!(html.contains("Vista previa"), "{html}");
assert!(!html.contains(">Preview<"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_htmx_with_token_field_with_labels_overrides_the_preview_heading() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let labels = RichTextLabels::new().preview_heading("Vista previa");
let html = rich_text_area_htmx_with_token_field_with_labels(
&cs,
"body",
"Body",
"/p",
"_one_time",
&labels,
)
.into_string();
assert!(html.contains("Vista previa"), "{html}");
assert!(html.contains(r#"hx-params="not _one_time""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_rich_text_area_with_labels_keeps_the_required_signal_and_labels() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let labels = RichTextLabels::new().hint("Se admite Markdown.");
let html = required_rich_text_area_with_labels(&cs, "body", "Body", &labels).into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("Se admite Markdown."), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_rich_text_area_htmx_with_labels_keeps_the_required_signal_and_labels() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let labels = RichTextLabels::new().preview_heading("Vista previa");
let html = required_rich_text_area_htmx_with_labels(&cs, "body", "Body", "/p", &labels)
.into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("Vista previa"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_rich_text_area_htmx_with_token_field_with_labels_keeps_the_required_signal_and_labels()
{
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let labels = RichTextLabels::new().preview_heading("Vista previa");
let html = required_rich_text_area_htmx_with_token_field_with_labels(
&cs,
"body",
"Body",
"/p",
"_one_time",
&labels,
)
.into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("Vista previa"), "{html}");
assert!(html.contains(r#"hx-params="not _one_time""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_with_labels_default_labels_match_rich_text_area() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: "Hello **world**".into(),
});
assert_eq!(
rich_text_area_with_labels(&cs, "body", "Body", &RichTextLabels::new()).into_string(),
rich_text_area(&cs, "body", "Body").into_string()
);
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_htmx_with_labels_default_labels_match_rich_text_area_htmx() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F { body: "hi".into() });
assert_eq!(
rich_text_area_htmx_with_labels(&cs, "body", "Body", "/p", &RichTextLabels::new())
.into_string(),
rich_text_area_htmx(&cs, "body", "Body", "/p").into_string()
);
}
#[cfg(feature = "maud")]
#[test]
fn changeset_form_exposes_the_rich_text_labels_helpers() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let form = ChangesetForm {
changeset: Changeset::new(F { body: "hi".into() }),
csrf_token: None,
csrf_field: "_csrf".to_owned(),
files: None,
};
let labels = RichTextLabels::new().hint("Se admite Markdown.");
assert_eq!(
form.rich_text_area_with_labels("body", "Body", &labels)
.into_string(),
rich_text_area_with_labels(&form.changeset, "body", "Body", &labels).into_string()
);
assert_eq!(
form.rich_text_area_htmx_with_labels("body", "Body", "/p", &labels)
.into_string(),
rich_text_area_htmx_with_labels(&form.changeset, "body", "Body", "/p", &labels)
.into_string()
);
assert_eq!(
form.rich_text_area_htmx_with_token_field_with_labels(
"body",
"Body",
"/p",
"_one_time",
&labels
)
.into_string(),
rich_text_area_htmx_with_token_field_with_labels(
&form.changeset,
"body",
"Body",
"/p",
"_one_time",
&labels
)
.into_string()
);
}
#[cfg(feature = "maud")]
#[test]
fn required_rich_text_area_signals_a_required_field() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let html = required_rich_text_area(&cs, "body", "Body").into_string();
assert!(html.contains("required"), "{html}");
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("<textarea"), "{html}");
assert!(html.contains("autumn-rich-text"), "{html}");
let optional = rich_text_area(&cs, "body", "Body").into_string();
assert!(!optional.contains(r#"aria-required="true""#), "{optional}");
}
#[cfg(feature = "maud")]
#[test]
fn required_rich_text_area_htmx_keeps_both_the_preview_and_the_signal() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let html =
required_rich_text_area_htmx(&cs, "body", "Body", "/posts/preview/body").into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains(r#"hx-post="/posts/preview/body""#), "{html}");
assert!(html.contains(r##"hx-target="#body-preview""##), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_renders_a_labeled_markdown_editor() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: "Hello **world**".into(),
});
let html = rich_text_area(&cs, "body", "Body").into_string();
assert!(html.contains("<textarea"), "{html}");
assert!(html.contains(r#"name="body""#), "{html}");
assert!(html.contains(r#"id="body""#), "{html}");
assert!(html.contains(r#"<label for="body""#), "{html}");
assert!(html.contains("Body"), "{html}");
assert!(html.contains("Hello **world**"), "{html}");
assert!(html.to_lowercase().contains("markdown"), "{html}");
assert!(html.contains("autumn-rich-text"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_renders_a_minimal_markdown_toolbar() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let html = rich_text_area(&cs, "body", "Body").into_string();
assert!(html.contains(r#"role="group""#), "{html}");
assert!(html.contains("autumn-rich-text__toolbar"), "{html}");
assert!(
html.to_lowercase()
.contains(r#"aria-label="markdown formatting"#),
"{html}"
);
for token in [
"**bold**",
"_italic_",
"[text](url)",
"- item",
"# Heading",
"> quote",
] {
assert!(
html.contains(&maud::html! { (token) }.into_string()),
"toolbar is missing the {token:?} affordance:\n{html}"
);
}
assert!(!html.contains("<script"), "{html}");
assert!(!html.contains("<button"), "{html}");
assert!(!html.contains("onclick"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_escapes_the_editor_value() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: "</textarea><script>alert(1)</script>".into(),
});
let html = rich_text_area(&cs, "body", "Body").into_string();
assert!(!html.contains("<script"), "{html}");
assert!(!html.contains("</textarea><script"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_describes_itself_with_hint_and_errors() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let ok = rich_text_area(&cs, "body", "Body").into_string();
assert!(ok.contains(r#"aria-describedby="body-hint""#), "{ok}");
assert!(ok.contains(r#"aria-invalid="false""#), "{ok}");
let mut errors = HashMap::new();
errors.insert("body".to_string(), vec!["must not be blank".to_string()]);
let cs = Changeset::from_errors(
F {
body: String::new(),
},
errors,
);
let bad = rich_text_area(&cs, "body", "Body").into_string();
assert!(
bad.contains(r#"aria-describedby="body-hint body-error""#),
"{bad}"
);
assert!(bad.contains(r#"aria-invalid="true""#), "{bad}");
assert!(bad.contains(r#"role="alert""#), "{bad}");
assert!(bad.contains("must not be blank"), "{bad}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_htmx_wires_a_live_preview_with_no_javascript() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F { body: "hi".into() });
let html = rich_text_area_htmx(&cs, "body", "Body", "/posts/preview/body").into_string();
assert!(html.contains(r#"hx-post="/posts/preview/body""#), "{html}");
assert!(html.contains(r##"hx-target="#body-preview""##), "{html}");
assert!(html.contains(r#"hx-swap="innerHTML""#), "{html}");
assert!(html.contains("hx-trigger="), "{html}");
assert!(html.contains(r#"hx-params="not _submit_token""#), "{html}");
assert!(html.contains(r#"id="body-preview""#), "{html}");
assert!(!html.contains("aria-live"), "{html}");
assert!(html.contains(r#"role="region""#), "{html}");
assert!(html.contains("aria-labelledby="), "{html}");
assert!(!html.contains("<script"), "{html}");
assert!(!html.contains("onkeyup"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_htmx_with_token_field_filters_the_configured_field() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let html = rich_text_area_htmx_with_token_field(
&cs,
"body",
"Body",
"/posts/preview/body",
"_one_time",
)
.into_string();
assert!(html.contains(r#"hx-params="not _one_time""#), "{html}");
assert!(!html.contains("_submit_token"), "{html}");
}
#[cfg(all(feature = "maud", feature = "markdown"))]
#[test]
fn rich_text_area_htmx_preview_pane_is_prerendered_and_sanitized() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: "**bold** [x](javascript:alert(1))".into(),
});
let html = rich_text_area_htmx(&cs, "body", "Body", "/posts/preview/body").into_string();
assert!(html.contains("<strong>bold</strong>"), "{html}");
let pane = html
.split_once(r#"id="body-preview""#)
.expect("preview pane present")
.1;
assert!(!pane.to_ascii_lowercase().contains("javascript:"), "{pane}");
assert!(!pane.contains("href="), "{pane}");
assert!(
html.contains("[x](javascript:alert(1))</textarea>"),
"{html}"
);
}
#[cfg(feature = "maud")]
#[test]
fn changeset_form_exposes_the_rich_text_helpers() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let form = ChangesetForm {
changeset: Changeset::new(F { body: "hi".into() }),
csrf_token: None,
csrf_field: "_csrf".to_owned(),
files: None,
};
assert_eq!(
form.rich_text_area("body", "Body").into_string(),
rich_text_area(&form.changeset, "body", "Body").into_string()
);
assert_eq!(
form.rich_text_area_htmx("body", "Body", "/p").into_string(),
rich_text_area_htmx(&form.changeset, "body", "Body", "/p").into_string()
);
}
#[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);
let decoded: Decoded = from_urlencoded(b"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 mixed_case_urlencoded_content_type_is_accepted() {
async fn handler(_form: ChangesetForm<TestForm>) -> &'static str {
"ok"
}
for ct in [
"Application/X-WWW-Form-Urlencoded",
"APPLICATION/X-WWW-FORM-URLENCODED; charset=UTF-8",
] {
let req = axum::http::Request::builder()
.method("POST")
.uri("/test")
.header("Content-Type", ct)
.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::OK, "{ct}");
}
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn mixed_case_multipart_content_type_is_accepted() {
async fn handler(_form: ChangesetForm<TestForm>) -> &'static str {
"ok"
}
let mut req = multipart_req("/test", "name", "Alice");
let ct = req.headers()["content-type"]
.to_str()
.unwrap()
.replace("multipart/form-data", "Multipart/Form-Data");
req.headers_mut()
.insert("content-type", ct.parse().unwrap());
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[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);
}
#[derive(serde::Deserialize, serde::Serialize, validator::Validate)]
struct NulTestForm {
#[validate(length(min = 3))]
name: String,
#[serde(default)]
body: String,
}
#[tokio::test]
async fn nul_byte_in_text_field_is_a_field_error() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!(
"valid={} body_errors={}",
form.is_valid(),
form.errors_for("body").join("|")
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&body=before%00after"))
.await
.unwrap();
assert_body(
resp,
&format!("valid=false body_errors={NUL_CHARACTER_FIELD_ERROR}"),
)
.await;
}
#[tokio::test]
async fn nul_byte_is_stripped_from_the_redisplayed_value() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
form.into_changeset()
.field_value("body")
.unwrap_or_default()
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&body=before%00after"))
.await
.unwrap();
assert_body(resp, "beforeafter").await;
}
#[tokio::test]
async fn nul_byte_flags_only_the_offending_field() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!(
"name={} body={}",
form.errors_for("name").len(),
form.errors_for("body").len()
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&body=a%00b"))
.await
.unwrap();
assert_body(resp, "name=0 body=1").await;
}
#[tokio::test]
async fn form_without_nul_is_unaffected() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!("valid={} body={}", form.is_valid(), form.data().body)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&body=plain"))
.await
.unwrap();
assert_body(resp, "valid=true body=plain").await;
}
#[tokio::test]
async fn nul_byte_in_the_method_override_is_not_a_form_error() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!("valid={} errors={}", form.is_valid(), form.errors().len())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req(
"/test",
"_method=PA%00TCH&name=Alice&body=ok",
))
.await
.unwrap();
assert_body(resp, "valid=true errors=0").await;
}
#[tokio::test]
async fn nul_byte_in_the_submit_token_is_not_a_form_error() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!("valid={} errors={}", form.is_valid(), form.errors().len())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req(
"/test",
"_submit_token=to%00ken&name=Alice&body=ok",
))
.await
.unwrap();
assert_body(resp, "valid=true errors=0").await;
}
#[tokio::test]
async fn nul_byte_in_a_renamed_csrf_field_is_not_a_form_error() {
let mut req = urlencoded_req("/test", "authenticity=to%00ken&name=Alice&body=ok");
req.extensions_mut()
.insert(crate::security::csrf::CsrfFormField(
"authenticity".to_owned(),
));
let form = ChangesetForm::<NulTestForm>::from_request(req, &())
.await
.expect("extraction should succeed");
assert!(form.is_valid(), "errors: {:?}", form.errors());
}
#[tokio::test]
async fn nul_byte_in_an_unknown_key_is_reported_under_that_key() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!(
"valid={} unknown={}",
form.is_valid(),
form.errors_for("return_to").len()
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req(
"/test",
"return_to=a%00b&name=Alice&body=ok",
))
.await
.unwrap();
assert_body(resp, "valid=false unknown=1").await;
}
#[tokio::test]
async fn a_repeated_key_reports_once() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!("errors={}", form.errors_for("return_to").len())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req(
"/test",
"name=Alice&body=ok&return_to=a%00a&return_to=b%00b&return_to=c%00c",
))
.await
.unwrap();
assert_body(resp, "errors=1").await;
}
#[tokio::test]
async fn a_nul_in_a_key_never_renames_it_onto_a_real_field() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!("valid={} body={:?}", form.is_valid(), form.data().body)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&bod%00y=injected"))
.await
.unwrap();
assert_body(resp, r#"valid=true body="""#).await;
}
#[tokio::test]
async fn nul_byte_error_is_appended_to_existing_field_errors() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
let errs = form.errors_for("name");
format!(
"count={} has_nul={}",
errs.len(),
errs.iter().any(|e| e == NUL_CHARACTER_FIELD_ERROR)
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=a%00b&body="))
.await
.unwrap();
assert_body(resp, "count=2 has_nul=true").await;
}
#[tokio::test]
async fn nul_byte_in_the_csrf_field_is_not_a_form_error() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!("valid={} errors={}", form.is_valid(), form.errors().len())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "_csrf=to%00ken&name=Alice&body=ok"))
.await
.unwrap();
assert_body(resp, "valid=true errors=0").await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_nul_report_survives_the_blank_optional_retry() {
async fn handler(form: ChangesetForm<OptionalNumericForm>) -> String {
format!(
"age={:?} name_errors={}",
form.data().age,
form.errors_for("name").len()
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(multipart_req_multi(
"/test",
&[("name", "Ali\u{0}ce"), ("age", "")],
))
.await
.unwrap();
assert_body(resp, "age=None name_errors=1").await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_file_part_carrying_a_nul_is_untouched() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!("valid={} errors={}", form.is_valid(), form.errors().len())
}
let boundary = "----FormBoundary7MA4YWxkTrZu0gW";
let body = format!(
"--{boundary}\r\n\
Content-Disposition: form-data; name=\"name\"\r\n\r\n\
Alice\r\n\
--{boundary}\r\n\
Content-Disposition: form-data; name=\"avatar\"; filename=\"a.bin\"\r\n\
Content-Type: application/octet-stream\r\n\r\n\
bin\u{0}ary\r\n\
--{boundary}--\r\n"
);
let req = axum::http::Request::builder()
.method("POST")
.uri("/test")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap();
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_body(resp, "valid=true errors=0").await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_nul_byte_in_text_field_is_a_field_error() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!(
"valid={} body_errors={} body={}",
form.is_valid(),
form.errors_for("body").len(),
form.data().body
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(multipart_req_multi(
"/test",
&[("name", "Alice"), ("body", "before\u{0}after")],
))
.await
.unwrap();
assert_body(resp, "valid=false body_errors=1 body=beforeafter").await;
}
#[derive(serde::Deserialize, validator::Validate)]
struct TagsForm {
#[validate(length(min = 1))]
name: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
ids: Vec<i64>,
}
async fn tags_handler(form: ChangesetForm<TagsForm>) -> String {
format!(
"name={} tags={:?} ids={:?}",
form.data().name,
form.data().tags,
form.data().ids
)
}
#[tokio::test]
async fn urlencoded_repeated_keys_reach_vec_fields() {
let resp = Router::new()
.route("/test", post(tags_handler))
.oneshot(urlencoded_req(
"/test",
"name=Ann&tags=a&tags=b%20c&ids=1&ids=2",
))
.await
.unwrap();
assert_body(resp, r#"name=Ann tags=["a", "b c"] ids=[1, 2]"#).await;
}
#[tokio::test]
async fn urlencoded_repeated_scalar_is_422_naming_the_field() {
let resp = Router::new()
.route("/test", post(tags_handler))
.oneshot(urlencoded_req("/test", "name=a&name=b"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::UNPROCESSABLE_ENTITY);
assert!(body_text(resp).await.contains("`name`"));
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_repeated_keys_reach_vec_fields() {
let resp = Router::new()
.route("/test", post(tags_handler))
.oneshot(multipart_req_multi(
"/test",
&[("name", "Ann"), ("tags", "a"), ("tags", "b"), ("ids", "9")],
))
.await
.unwrap();
assert_body(resp, r#"name=Ann tags=["a", "b"] ids=[9]"#).await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_file_field_reaches_the_form() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
let file = form.file("avatar").expect("file part must reach the form");
format!(
"valid={} field={} name={} type={:?} bytes={:?}",
form.is_valid(),
file.field(),
file.file_name(),
file.content_type(),
std::str::from_utf8(file.bytes()).unwrap()
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(multipart_parts_req(
"/test",
&[
Part::text("name", "Alice"),
Part::file("avatar", "me.txt", "text/plain", b"hello"),
],
))
.await
.unwrap();
assert_body(
resp,
r#"valid=true field=avatar name=me.txt type=Some("text/plain") bytes="hello""#,
)
.await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_files_under_one_name_all_reach_the_form() {
async fn handler(mut form: ChangesetForm<TestForm>) -> String {
let names: Vec<String> = form
.files("photos")
.map(|f| f.file_name().to_owned())
.collect();
let taken = form.take_files();
format!(
"{names:?} taken={} left={}",
taken.len(),
form.files("photos").count()
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(multipart_parts_req(
"/test",
&[
Part::text("name", "Alice"),
Part::file("photos", "a.txt", "text/plain", b"a"),
Part::file("photos", "b.txt", "text/plain", b"b"),
],
))
.await
.unwrap();
assert_body(resp, r#"["a.txt", "b.txt"] taken=2 left=0"#).await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_empty_optional_file_input_is_skipped() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
format!(
"valid={} file={}",
form.is_valid(),
form.file("avatar").is_some()
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(multipart_parts_req(
"/test",
&[
Part::text("name", "Alice"),
Part::file("avatar", "", "application/octet-stream", b""),
],
))
.await
.unwrap();
assert_body(resp, "valid=true file=false").await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_file_obeys_the_upload_mime_allow_list() {
async fn handler(_form: ChangesetForm<TestForm>) -> &'static str {
"accepted"
}
let mut req = multipart_parts_req(
"/test",
&[
Part::text("name", "Alice"),
Part::file("avatar", "x.html", "image/png", b"<script>1</script>"),
],
);
req.extensions_mut()
.insert(crate::security::config::UploadConfig {
allowed_mime_types: vec!["image/png".to_owned()],
..Default::default()
});
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::BAD_REQUEST);
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_file_obeys_the_upload_size_cap() {
async fn handler(_form: ChangesetForm<TestForm>) -> &'static str {
"accepted"
}
let mut req = multipart_parts_req(
"/test",
&[
Part::text("name", "Alice"),
Part::file("avatar", "big.txt", "text/plain", &[b'x'; 64]),
],
);
req.extensions_mut()
.insert(crate::security::config::UploadConfig {
max_file_size_bytes: 16,
..Default::default()
});
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::PAYLOAD_TOO_LARGE);
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_empty_optional_file_input_is_skipped_under_a_mime_policy() {
#[derive(serde::Deserialize, validator::Validate)]
#[serde(deny_unknown_fields)]
struct Strict {
name: String,
}
async fn handler(form: ChangesetForm<Strict>) -> String {
format!(
"{} file={}",
form.data().name,
form.file("avatar").is_some()
)
}
let mut req = multipart_parts_req(
"/test",
&[
Part::text("name", "Alice"),
Part::file("avatar", "", "application/octet-stream", b""),
],
);
req.extensions_mut()
.insert(crate::security::config::UploadConfig {
allowed_mime_types: vec!["image/png".to_owned()],
..Default::default()
});
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_body(resp, "Alice file=false").await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_keeps_a_tighter_route_body_limit() {
async fn handler(_form: ChangesetForm<TestForm>) -> &'static str {
"accepted"
}
let req = multipart_parts_req(
"/test",
&[
Part::text("name", "Alice"),
Part::file("avatar", "big.txt", "text/plain", &[b'x'; 4096]),
],
);
let resp = Router::new()
.route("/test", post(handler))
.layer(axum::extract::DefaultBodyLimit::max(1024))
.oneshot(req)
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn urlencoded_form_has_no_files() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
format!("{}", form.files("avatar").count())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&avatar=x"))
.await
.unwrap();
assert_body(resp, "0").await;
}
#[cfg(feature = "multipart")]
struct Part<'a> {
name: &'a str,
file: Option<(&'a str, &'a str)>,
value: &'a [u8],
}
#[cfg(feature = "multipart")]
impl<'a> Part<'a> {
const fn text(name: &'a str, value: &'a str) -> Self {
Self {
name,
file: None,
value: value.as_bytes(),
}
}
const fn file(
name: &'a str,
file_name: &'a str,
content_type: &'a str,
value: &'a [u8],
) -> Self {
Self {
name,
file: Some((file_name, content_type)),
value,
}
}
}
#[cfg(feature = "multipart")]
fn multipart_parts_req(uri: &str, parts: &[Part<'_>]) -> axum::http::Request<Body> {
let boundary = "----FormBoundary7MA4YWxkTrZu0gW";
let mut body: Vec<u8> = Vec::new();
for part in parts {
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
match part.file {
Some((file_name, content_type)) => body.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"{}\"; filename=\"{file_name}\"\r\n\
Content-Type: {content_type}\r\n\r\n",
part.name
)
.as_bytes(),
),
None => body.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"{}\"\r\n\r\n",
part.name
)
.as_bytes(),
),
}
body.extend_from_slice(part.value);
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
axum::http::Request::builder()
.method("POST")
.uri(uri)
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap()
}
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);
}
}
}