pincer_core/
multipart.rs

1//! Multipart form data support for file uploads.
2//!
3//! This module provides types for building multipart/form-data requests,
4//! commonly used for file uploads and form submissions with binary data.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use pincer::multipart::{Form, Part};
10//!
11//! let form = Form::new()
12//!     .part(Part::text("name", "John Doe"))
13//!     .part(Part::file("avatar", "photo.jpg", photo_bytes));
14//!
15//! let (content_type, body) = form.into_body();
16//! ```
17
18use bytes::{BufMut, Bytes, BytesMut};
19
20/// A single part in a multipart form.
21///
22/// Each part can be text, binary data, or a file with optional filename
23/// and content type.
24#[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    /// Create a new part with the given name and data.
34    #[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    /// Create a text part.
45    ///
46    /// Sets the content type to `text/plain; charset=utf-8`.
47    #[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    /// Create a binary part.
58    ///
59    /// Sets the content type to `application/octet-stream`.
60    #[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    /// Create a file part with filename.
71    ///
72    /// The content type is guessed from the filename extension, or defaults
73    /// to `application/octet-stream` if unknown.
74    #[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    /// Set the filename for this part.
91    #[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    /// Set the content type for this part.
98    #[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    /// Get the part name.
105    #[must_use]
106    pub fn name(&self) -> &str {
107        &self.name
108    }
109
110    /// Get the filename, if set.
111    #[must_use]
112    pub fn filename(&self) -> Option<&str> {
113        self.filename.as_deref()
114    }
115
116    /// Get the content type, if set.
117    #[must_use]
118    pub fn content_type(&self) -> Option<&str> {
119        self.content_type.as_deref()
120    }
121
122    /// Get the part data.
123    #[must_use]
124    pub fn data(&self) -> &Bytes {
125        &self.data
126    }
127}
128
129/// Guess the content type from a filename extension.
130fn 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        // Images
139        "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        // Documents
147        "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        // Text
155        "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        // Archives
164        "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        // Audio/Video
170        "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        // Other
177        "wasm" => "application/wasm",
178        _ => "application/octet-stream",
179    }
180    .to_string()
181}
182
183/// A multipart form containing multiple parts.
184///
185/// Use the builder pattern to construct a form with multiple parts,
186/// then convert it to a body with `into_body()`.
187#[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    /// Create a new empty form with a random boundary.
201    #[must_use]
202    pub fn new() -> Self {
203        Self {
204            parts: Vec::new(),
205            boundary: generate_boundary(),
206        }
207    }
208
209    /// Create a new form with a custom boundary.
210    ///
211    /// The boundary should be a unique string that doesn't appear in any part data.
212    #[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    /// Add a part to the form.
221    #[must_use]
222    pub fn part(mut self, part: Part) -> Self {
223        self.parts.push(part);
224        self
225    }
226
227    /// Add a text field to the form.
228    #[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    /// Add a file to the form.
234    #[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    /// Get the boundary string.
245    #[must_use]
246    pub fn boundary(&self) -> &str {
247        &self.boundary
248    }
249
250    /// Get the parts in this form.
251    #[must_use]
252    pub fn parts(&self) -> &[Part] {
253        &self.parts
254    }
255
256    /// Get the Content-Type header value for this form.
257    ///
258    /// Returns `multipart/form-data; boundary=<boundary>`.
259    #[must_use]
260    pub fn content_type(&self) -> String {
261        format!("multipart/form-data; boundary={}", self.boundary)
262    }
263
264    /// Convert the form into a body.
265    ///
266    /// Returns a tuple of (content-type header value, body bytes).
267    #[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    /// Encode the form into bytes.
275    fn encode(&self) -> Bytes {
276        let mut buf = BytesMut::new();
277
278        for part in &self.parts {
279            // Boundary
280            buf.put_slice(b"--");
281            buf.put_slice(self.boundary.as_bytes());
282            buf.put_slice(b"\r\n");
283
284            // Content-Disposition
285            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            // Content-Type (optional)
296            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            // Empty line before data
303            buf.put_slice(b"\r\n");
304
305            // Data
306            buf.put_slice(&part.data);
307            buf.put_slice(b"\r\n");
308        }
309
310        // Final boundary
311        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
319/// Generate a random boundary string.
320fn 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}