Skip to main content

rig_http/http_client/
multipart.rs

1//! Transport-neutral multipart fields and byte encoding.
2//!
3//! ```
4//! use rig_http::http_client::multipart::MultipartForm;
5//!
6//! let (boundary, body) = MultipartForm::new().text("model", "example").encode();
7//! assert!(!boundary.is_empty());
8//! assert!(!body.is_empty());
9//! ```
10
11use bytes::Bytes;
12use mime::Mime;
13use std::borrow::Cow;
14
15/// A generic multipart form part that can represent text or binary data
16#[derive(Clone, Debug)]
17pub struct Part {
18    name: String,
19    content: PartContent,
20    filename: Option<String>,
21    content_type: Option<Mime>,
22}
23
24/// The payload of a multipart [`Part`].
25#[derive(Clone, Debug)]
26pub enum PartContent {
27    /// A text field.
28    Text(String),
29    /// Binary data (e.g. a file upload).
30    Binary(Bytes),
31}
32
33impl Part {
34    /// Create a text part
35    pub fn text(name: impl Into<String>, value: impl Into<String>) -> Self {
36        Self {
37            name: name.into(),
38            content: PartContent::Text(value.into()),
39            filename: None,
40            content_type: None,
41        }
42    }
43
44    /// Create a binary part (e.g., file upload)
45    pub fn bytes(name: impl Into<String>, data: impl Into<Bytes>) -> Self {
46        Self {
47            name: name.into(),
48            content: PartContent::Binary(data.into()),
49            filename: None,
50            content_type: None,
51        }
52    }
53
54    /// Set the filename for this part
55    pub fn filename(mut self, filename: impl Into<String>) -> Self {
56        self.filename = Some(filename.into());
57        self
58    }
59
60    /// Set the content type for this part
61    pub fn content_type(mut self, content_type: Mime) -> Self {
62        self.content_type = Some(content_type);
63        self
64    }
65
66    /// Get the part name
67    pub fn name(&self) -> &str {
68        &self.name
69    }
70
71    /// Get the filename if set
72    pub fn get_filename(&self) -> Option<&str> {
73        self.filename.as_deref()
74    }
75
76    /// Get the content type if set
77    pub fn get_content_type(&self) -> Option<&Mime> {
78        self.content_type.as_ref()
79    }
80
81    /// The part's payload.
82    pub fn content(&self) -> &PartContent {
83        &self.content
84    }
85
86    /// Split the part into its owned pieces: `(name, content, filename, content_type)`.
87    /// Transports use this to render the part in their native multipart type.
88    pub fn into_pieces(self) -> (String, PartContent, Option<String>, Option<Mime>) {
89        (self.name, self.content, self.filename, self.content_type)
90    }
91}
92
93/// Generic multipart form data container
94#[derive(Clone, Debug, Default)]
95pub struct MultipartForm {
96    parts: Vec<Part>,
97    boundary: Option<String>,
98}
99
100impl MultipartForm {
101    /// Create a new empty multipart form
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Add a part to the form
107    pub fn part(mut self, part: Part) -> Self {
108        self.parts.push(part);
109        self
110    }
111
112    /// Add a text field
113    pub fn text(self, name: impl Into<String>, value: impl Into<String>) -> Self {
114        self.part(Part::text(name, value))
115    }
116
117    /// Add a file/binary field
118    pub fn file(
119        self,
120        name: impl Into<String>,
121        filename: impl Into<String>,
122        content_type: Mime,
123        data: impl Into<Bytes>,
124    ) -> Self {
125        self.part(
126            Part::bytes(name, data)
127                .filename(filename)
128                .content_type(content_type),
129        )
130    }
131
132    /// Set a custom boundary (optional, one will be generated if not set)
133    pub fn boundary(mut self, boundary: impl Into<String>) -> Self {
134        self.boundary = Some(boundary.into());
135        self
136    }
137
138    /// Get the parts
139    pub fn parts(&self) -> &[Part] {
140        &self.parts
141    }
142
143    /// Consume the form, yielding its parts in order. Transports use this to
144    /// render the form in their native multipart type.
145    pub fn into_parts(self) -> Vec<Part> {
146        self.parts
147    }
148
149    /// Generate a boundary string
150    fn generate_boundary() -> String {
151        use std::time::{SystemTime, UNIX_EPOCH};
152        let timestamp = SystemTime::now()
153            .duration_since(UNIX_EPOCH)
154            .unwrap_or_default()
155            .as_nanos();
156        format!("----boundary{timestamp}")
157    }
158
159    /// Get or generate boundary
160    fn get_boundary(&self) -> Cow<'_, str> {
161        match &self.boundary {
162            Some(b) => Cow::Borrowed(b),
163            None => Cow::Owned(Self::generate_boundary()),
164        }
165    }
166
167    /// Encodes parts in insertion order, returning the boundary and body bytes.
168    /// Uses the configured boundary or generates one for this call. Names,
169    /// filenames, and boundaries are inserted verbatim; callers must ensure they
170    /// are safe for multipart headers and that the boundary does not occur in data.
171    pub fn encode(&self) -> (String, Bytes) {
172        let boundary = self.get_boundary();
173        let mut body = Vec::new();
174
175        for part in &self.parts {
176            body.extend_from_slice(b"--");
177            body.extend_from_slice(boundary.as_bytes());
178            body.extend_from_slice(b"\r\n");
179
180            body.extend_from_slice(b"Content-Disposition: form-data; name=\"");
181            body.extend_from_slice(part.name.as_bytes());
182            body.extend_from_slice(b"\"");
183
184            if let Some(filename) = &part.filename {
185                body.extend_from_slice(b"; filename=\"");
186                body.extend_from_slice(filename.as_bytes());
187                body.extend_from_slice(b"\"");
188            }
189            body.extend_from_slice(b"\r\n");
190
191            if let Some(content_type) = &part.content_type {
192                body.extend_from_slice(b"Content-Type: ");
193                body.extend_from_slice(content_type.as_ref().as_bytes());
194                body.extend_from_slice(b"\r\n");
195            }
196
197            body.extend_from_slice(b"\r\n");
198
199            match &part.content {
200                PartContent::Text(text) => body.extend_from_slice(text.as_bytes()),
201                PartContent::Binary(bytes) => body.extend_from_slice(bytes),
202            }
203
204            body.extend_from_slice(b"\r\n");
205        }
206
207        body.extend_from_slice(b"--");
208        body.extend_from_slice(boundary.as_bytes());
209        body.extend_from_slice(b"--\r\n");
210
211        (boundary.into_owned(), Bytes::from(body))
212    }
213}
214
215#[cfg(test)]
216mod tests;