libdd_common/
multipart.rs1#[derive(Debug, Clone)]
6pub struct MultipartPart {
7 pub name: String,
9 pub data: Vec<u8>,
11 pub filename: Option<String>,
13 pub content_type: Option<String>,
15}
16
17impl MultipartPart {
18 pub fn new(name: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
20 Self {
21 name: name.into(),
22 data: data.into(),
23 filename: None,
24 content_type: None,
25 }
26 }
27
28 pub fn filename(mut self, filename: impl Into<String>) -> Self {
30 self.filename = Some(filename.into());
31 self
32 }
33
34 pub fn content_type(mut self, content_type: impl Into<String>) -> Self {
36 self.content_type = Some(content_type.into());
37 self
38 }
39}
40
41const BOUNDARY: &str = "------------------------dd_multipart_boundary";
42
43#[derive(Debug)]
45pub struct MultipartFormData {
46 body: Vec<u8>,
47}
48
49impl MultipartFormData {
50 pub fn encode(parts: Vec<MultipartPart>) -> Self {
52 let mut body = Vec::new();
53
54 for part in parts {
55 body.extend_from_slice(b"--");
56 body.extend_from_slice(BOUNDARY.as_bytes());
57 body.extend_from_slice(b"\r\n");
58
59 body.extend_from_slice(b"Content-Disposition: form-data; name=\"");
61 body.extend_from_slice(part.name.as_bytes());
62 body.extend_from_slice(b"\"");
63 if let Some(filename) = &part.filename {
64 body.extend_from_slice(b"; filename=\"");
65 body.extend_from_slice(filename.as_bytes());
66 body.extend_from_slice(b"\"");
67 }
68 body.extend_from_slice(b"\r\n");
69
70 if let Some(ct) = &part.content_type {
72 body.extend_from_slice(b"Content-Type: ");
73 body.extend_from_slice(ct.as_bytes());
74 body.extend_from_slice(b"\r\n");
75 }
76
77 body.extend_from_slice(b"\r\n");
79
80 body.extend_from_slice(&part.data);
82 body.extend_from_slice(b"\r\n");
83 }
84
85 body.extend_from_slice(b"--");
87 body.extend_from_slice(BOUNDARY.as_bytes());
88 body.extend_from_slice(b"--\r\n");
89
90 Self { body }
91 }
92
93 pub fn content_type(&self) -> String {
95 format!("multipart/form-data; boundary={BOUNDARY}")
96 }
97
98 pub fn into_body(self) -> Vec<u8> {
100 self.body
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn encode_single_text_field() {
110 let form = MultipartFormData::encode(vec![MultipartPart::new("field", b"value".to_vec())]);
111
112 let body = String::from_utf8(form.into_body()).unwrap();
113 assert!(body.contains("Content-Disposition: form-data; name=\"field\""));
114 assert!(body.contains("value"));
115 assert!(body.contains(&format!("--{BOUNDARY}--")));
116 }
117
118 #[test]
119 fn encode_with_filename_and_content_type() {
120 let form = MultipartFormData::encode(vec![MultipartPart::new("file", b"data".to_vec())
121 .filename("test.bin")
122 .content_type("application/octet-stream")]);
123
124 let body = String::from_utf8(form.into_body()).unwrap();
125 assert!(body.contains("filename=\"test.bin\""));
126 assert!(body.contains("Content-Type: application/octet-stream"));
127 }
128
129 #[test]
130 fn encode_multiple_parts() {
131 let form = MultipartFormData::encode(vec![
132 MultipartPart::new("metadata", br#"{"id":"123"}"#.to_vec())
133 .content_type("application/json"),
134 MultipartPart::new("file", vec![0xDE, 0xAD, 0xBE, 0xEF])
135 .filename("data.bin")
136 .content_type("application/octet-stream"),
137 ]);
138
139 let body = form.into_body();
140 let body_str = String::from_utf8_lossy(&body);
141
142 assert!(body_str.contains("name=\"metadata\""));
144 assert!(body_str.contains("name=\"file\""));
145 assert!(body_str.contains("filename=\"data.bin\""));
146 }
147
148 #[test]
149 fn content_type_includes_boundary() {
150 let form = MultipartFormData::encode(vec![]);
151 assert_eq!(
152 form.content_type(),
153 format!("multipart/form-data; boundary={BOUNDARY}")
154 );
155 }
156}