1use bytes::{BufMut, Bytes, BytesMut};
19
20#[derive(Debug, Clone)]
25pub struct Part {
26 name: String,
27 filename: Option<String>,
28 content_type: Option<String>,
29 data: Bytes,
30}
31
32impl Part {
33 #[must_use]
35 pub fn new(name: impl Into<String>, data: impl Into<Bytes>) -> Self {
36 Self {
37 name: name.into(),
38 filename: None,
39 content_type: None,
40 data: data.into(),
41 }
42 }
43
44 #[must_use]
48 pub fn text(name: impl Into<String>, value: impl Into<String>) -> Self {
49 Self {
50 name: name.into(),
51 filename: None,
52 content_type: Some("text/plain; charset=utf-8".to_string()),
53 data: Bytes::from(value.into()),
54 }
55 }
56
57 #[must_use]
61 pub fn bytes(name: impl Into<String>, data: impl Into<Bytes>) -> Self {
62 Self {
63 name: name.into(),
64 filename: None,
65 content_type: Some("application/octet-stream".to_string()),
66 data: data.into(),
67 }
68 }
69
70 #[must_use]
75 pub fn file(
76 name: impl Into<String>,
77 filename: impl Into<String>,
78 data: impl Into<Bytes>,
79 ) -> Self {
80 let filename = filename.into();
81 let content_type = guess_content_type(&filename);
82 Self {
83 name: name.into(),
84 filename: Some(filename),
85 content_type: Some(content_type),
86 data: data.into(),
87 }
88 }
89
90 #[must_use]
92 pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
93 self.filename = Some(filename.into());
94 self
95 }
96
97 #[must_use]
99 pub fn with_content_type(mut self, content_type: impl Into<String>) -> Self {
100 self.content_type = Some(content_type.into());
101 self
102 }
103
104 #[must_use]
106 pub fn name(&self) -> &str {
107 &self.name
108 }
109
110 #[must_use]
112 pub fn filename(&self) -> Option<&str> {
113 self.filename.as_deref()
114 }
115
116 #[must_use]
118 pub fn content_type(&self) -> Option<&str> {
119 self.content_type.as_deref()
120 }
121
122 #[must_use]
124 pub fn data(&self) -> &Bytes {
125 &self.data
126 }
127}
128
129fn guess_content_type(filename: &str) -> String {
131 let extension = filename
132 .rsplit('.')
133 .next()
134 .map(str::to_lowercase)
135 .unwrap_or_default();
136
137 match extension.as_str() {
138 "jpg" | "jpeg" => "image/jpeg",
140 "png" => "image/png",
141 "gif" => "image/gif",
142 "webp" => "image/webp",
143 "svg" => "image/svg+xml",
144 "ico" => "image/x-icon",
145 "bmp" => "image/bmp",
146 "pdf" => "application/pdf",
148 "doc" => "application/msword",
149 "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
150 "xls" => "application/vnd.ms-excel",
151 "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
152 "ppt" => "application/vnd.ms-powerpoint",
153 "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
154 "txt" => "text/plain",
156 "html" | "htm" => "text/html",
157 "css" => "text/css",
158 "js" => "application/javascript",
159 "json" => "application/json",
160 "xml" => "application/xml",
161 "csv" => "text/csv",
162 "md" => "text/markdown",
163 "zip" => "application/zip",
165 "tar" => "application/x-tar",
166 "gz" | "gzip" => "application/gzip",
167 "rar" => "application/vnd.rar",
168 "7z" => "application/x-7z-compressed",
169 "mp3" => "audio/mpeg",
171 "wav" => "audio/wav",
172 "ogg" => "audio/ogg",
173 "mp4" => "video/mp4",
174 "webm" => "video/webm",
175 "avi" => "video/x-msvideo",
176 "wasm" => "application/wasm",
178 _ => "application/octet-stream",
179 }
180 .to_string()
181}
182
183#[derive(Debug, Clone)]
188pub struct Form {
189 parts: Vec<Part>,
190 boundary: String,
191}
192
193impl Default for Form {
194 fn default() -> Self {
195 Self::new()
196 }
197}
198
199impl Form {
200 #[must_use]
202 pub fn new() -> Self {
203 Self {
204 parts: Vec::new(),
205 boundary: generate_boundary(),
206 }
207 }
208
209 #[must_use]
213 pub fn with_boundary(boundary: impl Into<String>) -> Self {
214 Self {
215 parts: Vec::new(),
216 boundary: boundary.into(),
217 }
218 }
219
220 #[must_use]
222 pub fn part(mut self, part: Part) -> Self {
223 self.parts.push(part);
224 self
225 }
226
227 #[must_use]
229 pub fn text(self, name: impl Into<String>, value: impl Into<String>) -> Self {
230 self.part(Part::text(name, value))
231 }
232
233 #[must_use]
235 pub fn file(
236 self,
237 name: impl Into<String>,
238 filename: impl Into<String>,
239 data: impl Into<Bytes>,
240 ) -> Self {
241 self.part(Part::file(name, filename, data))
242 }
243
244 #[must_use]
246 pub fn boundary(&self) -> &str {
247 &self.boundary
248 }
249
250 #[must_use]
252 pub fn parts(&self) -> &[Part] {
253 &self.parts
254 }
255
256 #[must_use]
260 pub fn content_type(&self) -> String {
261 format!("multipart/form-data; boundary={}", self.boundary)
262 }
263
264 #[must_use]
268 pub fn into_body(self) -> (String, Bytes) {
269 let content_type = self.content_type();
270 let body = self.encode();
271 (content_type, body)
272 }
273
274 fn encode(&self) -> Bytes {
276 let mut buf = BytesMut::new();
277
278 for part in &self.parts {
279 buf.put_slice(b"--");
281 buf.put_slice(self.boundary.as_bytes());
282 buf.put_slice(b"\r\n");
283
284 buf.put_slice(b"Content-Disposition: form-data; name=\"");
286 buf.put_slice(part.name.as_bytes());
287 buf.put_slice(b"\"");
288 if let Some(filename) = &part.filename {
289 buf.put_slice(b"; filename=\"");
290 buf.put_slice(filename.as_bytes());
291 buf.put_slice(b"\"");
292 }
293 buf.put_slice(b"\r\n");
294
295 if let Some(content_type) = &part.content_type {
297 buf.put_slice(b"Content-Type: ");
298 buf.put_slice(content_type.as_bytes());
299 buf.put_slice(b"\r\n");
300 }
301
302 buf.put_slice(b"\r\n");
304
305 buf.put_slice(&part.data);
307 buf.put_slice(b"\r\n");
308 }
309
310 buf.put_slice(b"--");
312 buf.put_slice(self.boundary.as_bytes());
313 buf.put_slice(b"--\r\n");
314
315 buf.freeze()
316 }
317}
318
319fn generate_boundary() -> String {
321 use std::time::{SystemTime, UNIX_EPOCH};
322
323 let timestamp = SystemTime::now()
324 .duration_since(UNIX_EPOCH)
325 .map(|d| d.as_nanos())
326 .unwrap_or(0);
327
328 format!("----PincerBoundary{timestamp:x}")
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn part_text() {
337 let part = Part::text("field", "value");
338 assert_eq!(part.name(), "field");
339 assert_eq!(part.data().as_ref(), b"value");
340 assert_eq!(part.content_type(), Some("text/plain; charset=utf-8"));
341 assert!(part.filename().is_none());
342 }
343
344 #[test]
345 fn part_bytes() {
346 let part = Part::bytes("data", vec![1, 2, 3]);
347 assert_eq!(part.name(), "data");
348 assert_eq!(part.data().as_ref(), &[1, 2, 3]);
349 assert_eq!(part.content_type(), Some("application/octet-stream"));
350 }
351
352 #[test]
353 fn part_file() {
354 let part = Part::file("upload", "photo.jpg", vec![0xFF, 0xD8, 0xFF]);
355 assert_eq!(part.name(), "upload");
356 assert_eq!(part.filename(), Some("photo.jpg"));
357 assert_eq!(part.content_type(), Some("image/jpeg"));
358 }
359
360 #[test]
361 fn part_with_modifiers() {
362 let part = Part::new("field", "data")
363 .with_filename("custom.bin")
364 .with_content_type("application/custom");
365 assert_eq!(part.filename(), Some("custom.bin"));
366 assert_eq!(part.content_type(), Some("application/custom"));
367 }
368
369 #[test]
370 fn form_empty() {
371 let form = Form::new();
372 assert!(form.parts().is_empty());
373 assert!(form.boundary().starts_with("----PincerBoundary"));
374 }
375
376 #[test]
377 fn form_with_parts() {
378 let form = Form::new().text("name", "John").file(
379 "avatar",
380 "photo.png",
381 vec![0x89, 0x50, 0x4E, 0x47],
382 );
383
384 assert_eq!(form.parts().len(), 2);
385 assert_eq!(form.parts().first().expect("part 0").name(), "name");
386 assert_eq!(form.parts().get(1).expect("part 1").name(), "avatar");
387 }
388
389 #[test]
390 fn form_content_type() {
391 let form = Form::with_boundary("test-boundary");
392 assert_eq!(
393 form.content_type(),
394 "multipart/form-data; boundary=test-boundary"
395 );
396 }
397
398 #[test]
399 fn form_encode() {
400 let form = Form::with_boundary("boundary123").text("field", "value");
401
402 let (content_type, body) = form.into_body();
403
404 assert_eq!(content_type, "multipart/form-data; boundary=boundary123");
405
406 let body_str = String::from_utf8_lossy(&body);
407 assert!(body_str.contains("--boundary123\r\n"));
408 assert!(body_str.contains("Content-Disposition: form-data; name=\"field\"\r\n"));
409 assert!(body_str.contains("value\r\n"));
410 assert!(body_str.contains("--boundary123--\r\n"));
411 }
412
413 #[test]
414 fn form_encode_with_file() {
415 let form = Form::with_boundary("boundary456").file("upload", "test.txt", "file content");
416
417 let (_, body) = form.into_body();
418 let body_str = String::from_utf8_lossy(&body);
419
420 assert!(body_str.contains("name=\"upload\"; filename=\"test.txt\""));
421 assert!(body_str.contains("Content-Type: text/plain\r\n"));
422 assert!(body_str.contains("file content\r\n"));
423 }
424
425 #[test]
426 fn guess_content_type_common() {
427 assert_eq!(guess_content_type("photo.jpg"), "image/jpeg");
428 assert_eq!(guess_content_type("photo.jpeg"), "image/jpeg");
429 assert_eq!(guess_content_type("image.png"), "image/png");
430 assert_eq!(guess_content_type("doc.pdf"), "application/pdf");
431 assert_eq!(guess_content_type("data.json"), "application/json");
432 assert_eq!(
433 guess_content_type("unknown.xyz"),
434 "application/octet-stream"
435 );
436 }
437
438 #[test]
439 fn guess_content_type_case_insensitive() {
440 assert_eq!(guess_content_type("PHOTO.JPG"), "image/jpeg");
441 assert_eq!(guess_content_type("Image.PNG"), "image/png");
442 }
443}