rig/http_client/
multipart.rs

1use bytes::Bytes;
2use mime::Mime;
3use std::borrow::Cow;
4
5/// A generic multipart form part that can represent text or binary data
6#[derive(Clone, Debug)]
7pub struct Part {
8    name: String,
9    content: PartContent,
10    filename: Option<String>,
11    content_type: Option<Mime>,
12}
13
14#[derive(Clone, Debug)]
15enum PartContent {
16    Text(String),
17    Binary(Bytes),
18}
19
20impl Part {
21    /// Create a text part
22    pub fn text(name: impl Into<String>, value: impl Into<String>) -> Self {
23        Self {
24            name: name.into(),
25            content: PartContent::Text(value.into()),
26            filename: None,
27            content_type: None,
28        }
29    }
30
31    /// Create a binary part (e.g., file upload)
32    pub fn bytes(name: impl Into<String>, data: impl Into<Bytes>) -> Self {
33        Self {
34            name: name.into(),
35            content: PartContent::Binary(data.into()),
36            filename: None,
37            content_type: None,
38        }
39    }
40
41    /// Set the filename for this part
42    pub fn filename(mut self, filename: impl Into<String>) -> Self {
43        self.filename = Some(filename.into());
44        self
45    }
46
47    /// Set the content type for this part
48    pub fn content_type(mut self, content_type: Mime) -> Self {
49        self.content_type = Some(content_type);
50        self
51    }
52
53    /// Get the part name
54    pub fn name(&self) -> &str {
55        &self.name
56    }
57
58    /// Get the filename if set
59    pub fn get_filename(&self) -> Option<&str> {
60        self.filename.as_deref()
61    }
62
63    /// Get the content type if set
64    pub fn get_content_type(&self) -> Option<&Mime> {
65        self.content_type.as_ref()
66    }
67}
68
69/// Generic multipart form data container
70#[derive(Clone, Debug, Default)]
71pub struct MultipartForm {
72    parts: Vec<Part>,
73    boundary: Option<String>,
74}
75
76impl MultipartForm {
77    /// Create a new empty multipart form
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Add a part to the form
83    pub fn part(mut self, part: Part) -> Self {
84        self.parts.push(part);
85        self
86    }
87
88    /// Add a text field
89    pub fn text(self, name: impl Into<String>, value: impl Into<String>) -> Self {
90        self.part(Part::text(name, value))
91    }
92
93    /// Add a file/binary field
94    pub fn file(
95        self,
96        name: impl Into<String>,
97        filename: impl Into<String>,
98        content_type: Mime,
99        data: impl Into<Bytes>,
100    ) -> Self {
101        self.part(
102            Part::bytes(name, data)
103                .filename(filename)
104                .content_type(content_type),
105        )
106    }
107
108    /// Set a custom boundary (optional, one will be generated if not set)
109    pub fn boundary(mut self, boundary: impl Into<String>) -> Self {
110        self.boundary = Some(boundary.into());
111        self
112    }
113
114    /// Get the parts
115    pub fn parts(&self) -> &[Part] {
116        &self.parts
117    }
118
119    /// Generate a boundary string
120    fn generate_boundary() -> String {
121        use std::time::{SystemTime, UNIX_EPOCH};
122        let timestamp = SystemTime::now()
123            .duration_since(UNIX_EPOCH)
124            .unwrap()
125            .as_nanos();
126        format!("----boundary{}", timestamp)
127    }
128
129    /// Get or generate boundary
130    fn get_boundary(&self) -> Cow<'_, str> {
131        match &self.boundary {
132            Some(b) => Cow::Borrowed(b),
133            None => Cow::Owned(Self::generate_boundary()),
134        }
135    }
136
137    /// Encode the multipart form to bytes with the given boundary
138    pub fn encode(&self) -> (String, Bytes) {
139        let boundary = self.get_boundary();
140        let mut body = Vec::new();
141
142        for part in &self.parts {
143            body.extend_from_slice(b"--");
144            body.extend_from_slice(boundary.as_bytes());
145            body.extend_from_slice(b"\r\n");
146
147            // Content-Disposition header
148            body.extend_from_slice(b"Content-Disposition: form-data; name=\"");
149            body.extend_from_slice(part.name.as_bytes());
150            body.extend_from_slice(b"\"");
151
152            if let Some(filename) = &part.filename {
153                body.extend_from_slice(b"; filename=\"");
154                body.extend_from_slice(filename.as_bytes());
155                body.extend_from_slice(b"\"");
156            }
157            body.extend_from_slice(b"\r\n");
158
159            // Content-Type header if specified
160            if let Some(content_type) = &part.content_type {
161                body.extend_from_slice(b"Content-Type: ");
162                body.extend_from_slice(content_type.as_ref().as_bytes());
163                body.extend_from_slice(b"\r\n");
164            }
165
166            body.extend_from_slice(b"\r\n");
167
168            // Content
169            match &part.content {
170                PartContent::Text(text) => body.extend_from_slice(text.as_bytes()),
171                PartContent::Binary(bytes) => body.extend_from_slice(bytes),
172            }
173
174            body.extend_from_slice(b"\r\n");
175        }
176
177        // Final boundary
178        body.extend_from_slice(b"--");
179        body.extend_from_slice(boundary.as_bytes());
180        body.extend_from_slice(b"--\r\n");
181
182        (boundary.into_owned(), Bytes::from(body))
183    }
184}
185
186impl From<MultipartForm> for reqwest::multipart::Form {
187    fn from(value: MultipartForm) -> Self {
188        let mut form = reqwest::multipart::Form::new();
189
190        for part in value.parts {
191            match part.content {
192                PartContent::Text(text) => {
193                    form = form.text(part.name, text);
194                }
195                PartContent::Binary(bytes) => {
196                    let mut req_part = reqwest::multipart::Part::bytes(bytes.to_vec());
197
198                    if let Some(filename) = part.filename {
199                        req_part = req_part.file_name(filename);
200                    }
201                    if let Some(content_type) = part.content_type {
202                        req_part = req_part.mime_str(content_type.as_ref()).unwrap();
203                    }
204
205                    form = form.part(part.name, req_part);
206                }
207            }
208        }
209
210        form
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_multipart_encoding() {
220        let form = MultipartForm::new()
221            .text("field1", "value1")
222            .text("field2", "value2");
223
224        let (boundary, body) = form.encode();
225        let body_str = String::from_utf8_lossy(&body);
226
227        assert!(body_str.contains("field1"));
228        assert!(body_str.contains("value1"));
229        assert!(body_str.contains(&boundary));
230    }
231
232    #[test]
233    fn test_file_part() {
234        let form = MultipartForm::new().file(
235            "upload",
236            "test.txt",
237            "text/plain".parse().unwrap(),
238            Bytes::from("file contents"),
239        );
240
241        let (_, body) = form.encode();
242        let body_str = String::from_utf8_lossy(&body);
243
244        assert!(body_str.contains("filename=\"test.txt\""));
245        assert!(body_str.contains("Content-Type: text/plain"));
246        assert!(body_str.contains("file contents"));
247    }
248}