use crate::Error;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
pub fn parse_form<T: DeserializeOwned>(body: &[u8]) -> Result<T, Error> {
serde_urlencoded::from_bytes(body)
.map_err(|e| Error::BadRequest(format!("Failed to parse form data: {}", e)))
}
pub fn parse_form_map(body: &[u8]) -> Result<HashMap<String, String>, Error> {
let form_data: Vec<(String, String)> = serde_urlencoded::from_bytes(body)
.map_err(|e| Error::BadRequest(format!("Failed to parse form data: {}", e)))?;
Ok(form_data.into_iter().collect())
}
#[derive(Debug, Clone)]
pub struct FormField {
pub name: String,
pub value: Option<String>,
pub file: Option<FormFile>,
}
#[derive(Debug, Clone)]
pub struct FormFile {
pub filename: String,
pub content_type: String,
pub size: usize,
pub data: Vec<u8>,
}
impl FormFile {
pub fn new(filename: String, content_type: String, data: Vec<u8>) -> Self {
let size = data.len();
Self {
filename,
content_type,
size,
data,
}
}
pub fn extension(&self) -> Option<&str> {
self.filename.rsplit('.').next()
}
pub fn is_image(&self) -> bool {
self.content_type.starts_with("image/")
}
pub fn exceeds_size(&self, max_bytes: usize) -> bool {
self.size > max_bytes
}
pub fn save_to(&self, path: &str) -> Result<(), Error> {
std::fs::write(path, &self.data)
.map_err(|e| Error::Internal(format!("Failed to save file: {}", e)))
}
pub async fn save_to_async(&self, path: &str) -> Result<(), Error> {
tokio::fs::write(path, &self.data)
.await
.map_err(|e| Error::Internal(format!("Failed to save file: {}", e)))
}
}
pub async fn save_files_parallel(files: Vec<(&FormFile, String)>) -> Result<Vec<String>, Error> {
use tokio::task::JoinSet;
let mut set = JoinSet::new();
for (file, path) in files {
let data = file.data.clone();
let path_clone = path.clone();
set.spawn(async move {
tokio::fs::write(&path_clone, &data)
.await
.map_err(|e| Error::Internal(format!("Failed to save file: {}", e)))?;
Ok::<_, Error>(path_clone)
});
}
let mut saved_paths = Vec::new();
while let Some(result) = set.join_next().await {
saved_paths.push(result.map_err(|e| Error::Internal(e.to_string()))??);
}
Ok(saved_paths)
}
pub struct MultipartParser {
boundary: String,
}
impl MultipartParser {
pub fn from_content_type(content_type: &str) -> Result<Self, Error> {
let boundary = content_type
.split(';')
.find_map(|part| {
let part = part.trim();
if part.starts_with("boundary=") {
Some(
part.trim_start_matches("boundary=")
.trim_matches('"')
.to_string(),
)
} else {
None
}
})
.ok_or_else(|| Error::BadRequest("Missing boundary in Content-Type".to_string()))?;
Ok(Self { boundary })
}
pub fn parse(&self, body: &[u8]) -> Result<Vec<FormField>, Error> {
let mut fields = Vec::new();
let boundary_marker = format!("--{}", self.boundary);
let delimiter = boundary_marker.as_bytes();
let finder = memchr::memmem::Finder::new(delimiter);
let positions: Vec<usize> = finder.find_iter(body).collect();
for pair in positions.windows(2) {
let mut part = &body[pair[0] + delimiter.len()..pair[1]];
if part.starts_with(b"--") {
continue;
}
if let Some(rest) = part.strip_prefix(b"\r\n") {
part = rest;
} else if let Some(rest) = part.strip_prefix(b"\n") {
part = rest;
}
if let Some(rest) = part.strip_suffix(b"\r\n") {
part = rest;
} else if let Some(rest) = part.strip_suffix(b"\n") {
part = rest;
}
if let Some(field) = self.parse_part(part)? {
fields.push(field);
}
}
Ok(fields)
}
fn parse_part(&self, part: &[u8]) -> Result<Option<FormField>, Error> {
if part.is_empty() {
return Ok(None);
}
let (header_block, content) = match memchr::memmem::find(part, b"\r\n\r\n") {
Some(pos) => (&part[..pos], &part[pos + 4..]),
None => match memchr::memmem::find(part, b"\n\n") {
Some(pos) => (&part[..pos], &part[pos + 2..]),
None => (part, &part[part.len()..]),
},
};
let headers = String::from_utf8_lossy(header_block);
let mut name = None;
let mut filename = None;
let mut content_type = None;
for line in headers.lines() {
if line.starts_with("Content-Disposition:") {
for attr in line.split(';') {
let attr = attr.trim();
if attr.starts_with("name=") {
name = Some(
attr.trim_start_matches("name=")
.trim_matches('"')
.to_string(),
);
} else if attr.starts_with("filename=") {
filename = Some(
attr.trim_start_matches("filename=")
.trim_matches('"')
.to_string(),
);
}
}
} else if line.starts_with("Content-Type:") {
content_type = Some(line.trim_start_matches("Content-Type:").trim().to_string());
}
}
let name = name.ok_or_else(|| Error::BadRequest("Missing field name".to_string()))?;
if let Some(filename) = filename {
let file = FormFile::new(
filename,
content_type.unwrap_or_else(|| "application/octet-stream".to_string()),
content.to_vec(),
);
Ok(Some(FormField {
name,
value: None,
file: Some(file),
}))
} else {
Ok(Some(FormField {
name,
value: Some(String::from_utf8_lossy(content).into_owned()),
file: None,
}))
}
}
pub fn to_map(fields: Vec<FormField>) -> HashMap<String, String> {
fields
.into_iter()
.filter_map(|field| field.value.map(|value| (field.name, value)))
.collect()
}
pub fn get_files(fields: &[FormField]) -> Vec<(String, &FormFile)> {
fields
.iter()
.filter_map(|field| field.file.as_ref().map(|file| (field.name.clone(), file)))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_form_map() {
let body = b"name=John+Doe&email=john%40example.com&age=30";
let form = parse_form_map(body).unwrap();
assert_eq!(form.get("name"), Some(&"John Doe".to_string()));
assert_eq!(form.get("email"), Some(&"john@example.com".to_string()));
assert_eq!(form.get("age"), Some(&"30".to_string()));
}
#[test]
fn test_form_file_extension() {
let file = FormFile::new(
"document.pdf".to_string(),
"application/pdf".to_string(),
vec![1, 2, 3],
);
assert_eq!(file.extension(), Some("pdf"));
}
#[test]
fn test_form_file_is_image() {
let image = FormFile::new("photo.jpg".to_string(), "image/jpeg".to_string(), vec![]);
assert!(image.is_image());
let doc = FormFile::new("doc.pdf".to_string(), "application/pdf".to_string(), vec![]);
assert!(!doc.is_image());
}
#[test]
fn test_form_file_size_check() {
let file = FormFile::new(
"file.txt".to_string(),
"text/plain".to_string(),
vec![0; 1024], );
assert!(!file.exceeds_size(2048)); assert!(file.exceeds_size(512)); }
#[test]
fn test_multipart_parser_from_content_type() {
let content_type = "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW";
let parser = MultipartParser::from_content_type(content_type).unwrap();
assert_eq!(parser.boundary, "----WebKitFormBoundary7MA4YWxkTrZu0gW");
}
#[test]
fn test_multipart_binary_roundtrip() {
let file_data: Vec<u8> = vec![
b' ', b'\t', 0x00, 0xFF, 0xFE, b'\r', b'\n', 0x80, 0xC3, 0x01, b'\n', b' ',
];
let mut body = Vec::new();
body.extend_from_slice(b"--XBOUNDARY\r\n");
body.extend_from_slice(
b"Content-Disposition: form-data; name=\"file\"; filename=\"blob.bin\"\r\n",
);
body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
body.extend_from_slice(&file_data);
body.extend_from_slice(b"\r\n--XBOUNDARY\r\n");
body.extend_from_slice(b"Content-Disposition: form-data; name=\"note\"\r\n\r\n");
body.extend_from_slice(b"hello world");
body.extend_from_slice(b"\r\n--XBOUNDARY--\r\n");
let parser =
MultipartParser::from_content_type("multipart/form-data; boundary=XBOUNDARY").unwrap();
let fields = parser.parse(&body).unwrap();
assert_eq!(fields.len(), 2);
let file = fields[0].file.as_ref().unwrap();
assert_eq!(fields[0].name, "file");
assert_eq!(file.filename, "blob.bin");
assert_eq!(file.content_type, "application/octet-stream");
assert_eq!(file.data, file_data);
assert_eq!(file.size, file_data.len());
assert_eq!(fields[1].name, "note");
assert_eq!(fields[1].value.as_deref(), Some("hello world"));
}
#[test]
fn test_multipart_parser_missing_boundary() {
let content_type = "multipart/form-data";
let result = MultipartParser::from_content_type(content_type);
assert!(result.is_err());
}
}