use std::fmt::{Display, Formatter};
use askama::filters::HtmlSafe;
use bytes::Bytes;
use cot::form::{AsFormField, FormFieldValidationError};
use cot::html::HtmlTag;
use crate::form::{FormField, FormFieldOptions, FormFieldValue, FormFieldValueError};
#[derive(Debug)]
pub struct FileField {
options: FormFieldOptions,
custom_options: FileFieldOptions,
filename: Option<String>,
content_type: Option<String>,
data: Option<Bytes>,
}
impl FormField for FileField {
type CustomOptions = FileFieldOptions;
fn with_options(options: FormFieldOptions, custom_options: Self::CustomOptions) -> Self {
Self {
options,
custom_options,
filename: None,
content_type: None,
data: None,
}
}
fn options(&self) -> &FormFieldOptions {
&self.options
}
fn value(&self) -> Option<&str> {
None
}
async fn set_value(&mut self, field: FormFieldValue<'_>) -> Result<(), FormFieldValueError> {
if !field.is_multipart() {
return Err(FormFieldValueError::multipart_required());
}
self.filename = field.filename().map(ToOwned::to_owned);
self.content_type = field.content_type().map(ToOwned::to_owned);
self.data = Some(field.into_bytes().await?);
Ok(())
}
}
#[derive(Debug, Default, Clone)]
pub struct FileFieldOptions {
pub accept: Option<Vec<String>>,
}
impl Display for FileField {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let mut tag = HtmlTag::input("file");
tag.attr("name", self.id());
tag.attr("id", self.id());
if self.options.required {
tag.bool_attr("required");
}
if let Some(accept) = &self.custom_options.accept {
tag.attr("accept", accept.join(","));
}
write!(f, "{}", tag.render())
}
}
impl HtmlSafe for FileField {}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct InMemoryUploadedFile {
filename: Option<String>,
content_type: Option<String>,
content: Bytes,
}
impl AsFormField for InMemoryUploadedFile {
type Type = FileField;
fn clean_value(field: &Self::Type) -> Result<Self, FormFieldValidationError> {
let data = if let Some(value) = &field.data {
if value.is_empty() {
Err(FormFieldValidationError::Required)
} else {
Ok(value)
}
} else {
Err(FormFieldValidationError::Required)
}?;
Ok(Self {
filename: field.filename.clone(),
content_type: field.content_type.clone(),
content: data.clone(),
})
}
fn to_field_value(&self) -> String {
String::new()
}
}
impl InMemoryUploadedFile {
#[must_use]
pub fn filename(&self) -> Option<&str> {
self.filename.as_deref()
}
#[must_use]
pub fn content_type(&self) -> Option<&str> {
self.content_type.as_deref()
}
#[must_use]
pub fn content(&self) -> &Bytes {
&self.content
}
}
#[cfg(test)]
mod tests {
use bytes::Bytes;
use futures_util::stream::once;
use multer::Multipart;
use super::*;
use crate::form::{FormField, FormFieldOptions, FormFieldValue};
#[test]
fn file_field_render() {
let field = FileField::with_options(
FormFieldOptions {
id: "test".to_owned(),
name: "test".to_owned(),
required: true,
},
FileFieldOptions {
accept: Some(vec!["image/*".to_string(), ".pdf".to_string()]),
},
);
let html = field.to_string();
assert!(html.contains("type=\"file\""));
assert!(html.contains("required"));
assert!(html.contains("accept=\"image/*,.pdf\""));
}
#[test]
fn file_field_render_no_accept() {
let field = FileField::with_options(
FormFieldOptions {
id: "test".to_owned(),
name: "test".to_owned(),
required: true,
},
FileFieldOptions { accept: None },
);
let html = field.to_string();
assert!(html.contains("type=\"file\""));
assert!(html.contains("required"));
assert!(!html.contains("accept="));
}
#[cot::test]
async fn file_field_clean_value() {
let mut field = FileField::with_options(
FormFieldOptions {
id: "test".to_owned(),
name: "test".to_owned(),
required: true,
},
FileFieldOptions { accept: None },
);
let boundary = "boundary";
let body = format!(
"--{boundary}\r\n\
Content-Disposition: form-data; name=\"test\"; filename=\"test.txt\"\r\n\
Content-Type: text/plain\r\n\
\r\n\
test content\r\n\
--{boundary}--\r\n"
);
let stream = once(async move { Ok::<_, std::io::Error>(Bytes::from(body)) });
let mut multipart = Multipart::new(stream, boundary);
let field_value = multipart.next_field().await.unwrap().unwrap();
let multipart = FormFieldValue::new_multipart(field_value);
field.set_value(multipart).await.unwrap();
let value = InMemoryUploadedFile::clean_value(&field).unwrap();
assert_eq!(value.filename(), Some("test.txt"));
assert_eq!(value.content_type(), Some("text/plain"));
assert_eq!(value.content(), &bytes::Bytes::from("test content"));
}
#[cot::test]
async fn file_field_clean_required() {
let field = FileField::with_options(
FormFieldOptions {
id: "test".to_owned(),
name: "test".to_owned(),
required: true,
},
FileFieldOptions { accept: None },
);
let value = InMemoryUploadedFile::clean_value(&field);
assert_eq!(value, Err(FormFieldValidationError::Required));
}
#[test]
fn in_memory_uploaded_file() {
let file = InMemoryUploadedFile {
filename: Some("test.txt".to_string()),
content_type: Some("text/plain".to_string()),
content: bytes::Bytes::from("test content"),
};
assert_eq!(file.filename(), Some("test.txt"));
assert_eq!(file.content_type(), Some("text/plain"));
assert_eq!(file.content(), &bytes::Bytes::from("test content"));
}
#[test]
fn in_memory_uploaded_file_no_metadata() {
let file = InMemoryUploadedFile {
filename: None,
content_type: None,
content: bytes::Bytes::from("test content"),
};
assert_eq!(file.filename(), None);
assert_eq!(file.content_type(), None);
assert_eq!(file.content(), &bytes::Bytes::from("test content"));
}
}