#[derive(Debug, Clone)]
pub struct MultipartPart {
pub name: String,
pub data: Vec<u8>,
pub filename: Option<String>,
pub content_type: Option<String>,
}
impl MultipartPart {
pub fn new(name: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
Self {
name: name.into(),
data: data.into(),
filename: None,
content_type: None,
}
}
pub fn filename(mut self, filename: impl Into<String>) -> Self {
self.filename = Some(filename.into());
self
}
pub fn content_type(mut self, content_type: impl Into<String>) -> Self {
self.content_type = Some(content_type.into());
self
}
}
const BOUNDARY: &str = "------------------------dd_multipart_boundary";
#[derive(Debug)]
pub struct MultipartFormData {
body: Vec<u8>,
}
impl MultipartFormData {
pub fn encode(parts: Vec<MultipartPart>) -> Self {
let mut body = Vec::new();
for part in parts {
body.extend_from_slice(b"--");
body.extend_from_slice(BOUNDARY.as_bytes());
body.extend_from_slice(b"\r\n");
body.extend_from_slice(b"Content-Disposition: form-data; name=\"");
body.extend_from_slice(part.name.as_bytes());
body.extend_from_slice(b"\"");
if let Some(filename) = &part.filename {
body.extend_from_slice(b"; filename=\"");
body.extend_from_slice(filename.as_bytes());
body.extend_from_slice(b"\"");
}
body.extend_from_slice(b"\r\n");
if let Some(ct) = &part.content_type {
body.extend_from_slice(b"Content-Type: ");
body.extend_from_slice(ct.as_bytes());
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(b"\r\n");
body.extend_from_slice(&part.data);
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(b"--");
body.extend_from_slice(BOUNDARY.as_bytes());
body.extend_from_slice(b"--\r\n");
Self { body }
}
pub fn content_type(&self) -> String {
format!("multipart/form-data; boundary={BOUNDARY}")
}
pub fn into_body(self) -> Vec<u8> {
self.body
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_single_text_field() {
let form = MultipartFormData::encode(vec![MultipartPart::new("field", b"value".to_vec())]);
let body = String::from_utf8(form.into_body()).unwrap();
assert!(body.contains("Content-Disposition: form-data; name=\"field\""));
assert!(body.contains("value"));
assert!(body.contains(&format!("--{BOUNDARY}--")));
}
#[test]
fn encode_with_filename_and_content_type() {
let form = MultipartFormData::encode(vec![MultipartPart::new("file", b"data".to_vec())
.filename("test.bin")
.content_type("application/octet-stream")]);
let body = String::from_utf8(form.into_body()).unwrap();
assert!(body.contains("filename=\"test.bin\""));
assert!(body.contains("Content-Type: application/octet-stream"));
}
#[test]
fn encode_multiple_parts() {
let form = MultipartFormData::encode(vec![
MultipartPart::new("metadata", br#"{"id":"123"}"#.to_vec())
.content_type("application/json"),
MultipartPart::new("file", vec![0xDE, 0xAD, 0xBE, 0xEF])
.filename("data.bin")
.content_type("application/octet-stream"),
]);
let body = form.into_body();
let body_str = String::from_utf8_lossy(&body);
assert!(body_str.contains("name=\"metadata\""));
assert!(body_str.contains("name=\"file\""));
assert!(body_str.contains("filename=\"data.bin\""));
}
#[test]
fn content_type_includes_boundary() {
let form = MultipartFormData::encode(vec![]);
assert_eq!(
form.content_type(),
format!("multipart/form-data; boundary={BOUNDARY}")
);
}
}