armature_core/
form.rs

1//! Form processing and multipart support
2
3use crate::Error;
4use serde::de::DeserializeOwned;
5use std::collections::HashMap;
6
7/// Parse URL-encoded form data
8pub fn parse_form<T: DeserializeOwned>(body: &[u8]) -> Result<T, Error> {
9    serde_urlencoded::from_bytes(body)
10        .map_err(|e| Error::BadRequest(format!("Failed to parse form data: {}", e)))
11}
12
13/// Parse URL-encoded form data into a HashMap
14pub fn parse_form_map(body: &[u8]) -> Result<HashMap<String, String>, Error> {
15    let form_data: Vec<(String, String)> = serde_urlencoded::from_bytes(body)
16        .map_err(|e| Error::BadRequest(format!("Failed to parse form data: {}", e)))?;
17
18    Ok(form_data.into_iter().collect())
19}
20
21/// Multipart form field
22#[derive(Debug, Clone)]
23pub struct FormField {
24    /// Field name
25    pub name: String,
26
27    /// Field value (for text fields)
28    pub value: Option<String>,
29
30    /// File data (for file fields)
31    pub file: Option<FormFile>,
32}
33
34/// Uploaded file data
35#[derive(Debug, Clone)]
36pub struct FormFile {
37    /// Original filename
38    pub filename: String,
39
40    /// Content type (MIME type)
41    pub content_type: String,
42
43    /// File size in bytes
44    pub size: usize,
45
46    /// File data
47    pub data: Vec<u8>,
48}
49
50impl FormFile {
51    /// Create a new form file
52    pub fn new(filename: String, content_type: String, data: Vec<u8>) -> Self {
53        let size = data.len();
54        Self {
55            filename,
56            content_type,
57            size,
58            data,
59        }
60    }
61
62    /// Get file extension
63    pub fn extension(&self) -> Option<&str> {
64        self.filename.rsplit('.').next()
65    }
66
67    /// Check if file is an image
68    pub fn is_image(&self) -> bool {
69        self.content_type.starts_with("image/")
70    }
71
72    /// Check if file size exceeds limit
73    pub fn exceeds_size(&self, max_bytes: usize) -> bool {
74        self.size > max_bytes
75    }
76
77    /// Save file to disk
78    pub fn save_to(&self, path: &str) -> Result<(), Error> {
79        std::fs::write(path, &self.data)
80            .map_err(|e| Error::Internal(format!("Failed to save file: {}", e)))
81    }
82
83    /// Save file to disk asynchronously
84    pub async fn save_to_async(&self, path: &str) -> Result<(), Error> {
85        tokio::fs::write(path, &self.data)
86            .await
87            .map_err(|e| Error::Internal(format!("Failed to save file: {}", e)))
88    }
89}
90
91/// Save multiple files in parallel
92///
93/// This function saves multiple uploaded files concurrently, providing
94/// significant performance improvements over sequential saves.
95///
96/// # Arguments
97///
98/// * `files` - Vector of tuples containing (file, destination_path)
99///
100/// # Returns
101///
102/// Returns a vector of saved file paths in the same order as input.
103///
104/// # Performance
105///
106/// - **Sequential:** O(n * disk_write_time)
107/// - **Parallel:** O(max(disk_write_times))
108/// - **Speedup:** 5-10x for batch file uploads
109///
110/// # Examples
111///
112/// ```no_run
113/// # use armature_core::form::*;
114/// # async fn example(files: Vec<FormFile>) -> Result<(), armature_core::Error> {
115/// // Save 10 files in parallel (5-10x faster)
116/// let file_paths: Vec<_> = files.iter()
117///     .enumerate()
118///     .map(|(i, file)| (file, format!("uploads/file_{}.dat", i)))
119///     .collect();
120///
121/// let saved = save_files_parallel(file_paths).await?;
122/// println!("Saved {} files", saved.len());
123/// # Ok(())
124/// # }
125/// ```
126pub async fn save_files_parallel(files: Vec<(&FormFile, String)>) -> Result<Vec<String>, Error> {
127    use tokio::task::JoinSet;
128
129    let mut set = JoinSet::new();
130
131    for (file, path) in files {
132        let data = file.data.clone();
133        let path_clone = path.clone();
134
135        set.spawn(async move {
136            tokio::fs::write(&path_clone, &data)
137                .await
138                .map_err(|e| Error::Internal(format!("Failed to save file: {}", e)))?;
139            Ok::<_, Error>(path_clone)
140        });
141    }
142
143    let mut saved_paths = Vec::new();
144    while let Some(result) = set.join_next().await {
145        saved_paths.push(result.map_err(|e| Error::Internal(e.to_string()))??);
146    }
147
148    Ok(saved_paths)
149}
150
151/// Multipart form data parser
152pub struct MultipartParser {
153    boundary: String,
154}
155
156impl MultipartParser {
157    /// Create a new multipart parser from Content-Type header
158    pub fn from_content_type(content_type: &str) -> Result<Self, Error> {
159        // Extract boundary from Content-Type header
160        // Example: "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW"
161        let boundary = content_type
162            .split(';')
163            .find_map(|part| {
164                let part = part.trim();
165                if part.starts_with("boundary=") {
166                    Some(
167                        part.trim_start_matches("boundary=")
168                            .trim_matches('"')
169                            .to_string(),
170                    )
171                } else {
172                    None
173                }
174            })
175            .ok_or_else(|| Error::BadRequest("Missing boundary in Content-Type".to_string()))?;
176
177        Ok(Self { boundary })
178    }
179
180    /// Parse multipart form data
181    pub fn parse(&self, body: &[u8]) -> Result<Vec<FormField>, Error> {
182        let mut fields = Vec::new();
183        let boundary_marker = format!("--{}", self.boundary);
184        let body_str = String::from_utf8_lossy(body);
185
186        // Split by boundary
187        let parts: Vec<&str> = body_str.split(&boundary_marker).collect();
188
189        for part in parts.iter().skip(1) {
190            if part.trim() == "--" || part.trim().is_empty() {
191                continue;
192            }
193
194            // Parse each part
195            if let Some(field) = self.parse_part(part)? {
196                fields.push(field);
197            }
198        }
199
200        Ok(fields)
201    }
202
203    /// Parse a single multipart part
204    fn parse_part(&self, part: &str) -> Result<Option<FormField>, Error> {
205        let lines: Vec<&str> = part.lines().collect();
206
207        if lines.is_empty() {
208            return Ok(None);
209        }
210
211        // Parse headers
212        let mut name = None;
213        let mut filename = None;
214        let mut content_type = None;
215        let mut content_start = 0;
216
217        for (i, line) in lines.iter().enumerate() {
218            if line.trim().is_empty() {
219                content_start = i + 1;
220                break;
221            }
222
223            if line.starts_with("Content-Disposition:") {
224                // Parse name and filename
225                for attr in line.split(';') {
226                    let attr = attr.trim();
227                    if attr.starts_with("name=") {
228                        name = Some(
229                            attr.trim_start_matches("name=")
230                                .trim_matches('"')
231                                .to_string(),
232                        );
233                    } else if attr.starts_with("filename=") {
234                        filename = Some(
235                            attr.trim_start_matches("filename=")
236                                .trim_matches('"')
237                                .to_string(),
238                        );
239                    }
240                }
241            } else if line.starts_with("Content-Type:") {
242                content_type = Some(line.trim_start_matches("Content-Type:").trim().to_string());
243            }
244        }
245
246        let name = name.ok_or_else(|| Error::BadRequest("Missing field name".to_string()))?;
247
248        // Get content
249        let content_lines = &lines[content_start..];
250        let content = content_lines.join("\n").trim().to_string();
251
252        // Create field
253        if let Some(filename) = filename {
254            // File field
255            let file = FormFile::new(
256                filename,
257                content_type.unwrap_or_else(|| "application/octet-stream".to_string()),
258                content.into_bytes(),
259            );
260            Ok(Some(FormField {
261                name,
262                value: None,
263                file: Some(file),
264            }))
265        } else {
266            // Text field
267            Ok(Some(FormField {
268                name,
269                value: Some(content),
270                file: None,
271            }))
272        }
273    }
274
275    /// Convert parsed fields to HashMap
276    pub fn to_map(fields: Vec<FormField>) -> HashMap<String, String> {
277        fields
278            .into_iter()
279            .filter_map(|field| field.value.map(|value| (field.name, value)))
280            .collect()
281    }
282
283    /// Get files from parsed fields
284    pub fn get_files(fields: &[FormField]) -> Vec<(String, &FormFile)> {
285        fields
286            .iter()
287            .filter_map(|field| field.file.as_ref().map(|file| (field.name.clone(), file)))
288            .collect()
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn test_parse_form_map() {
298        let body = b"name=John+Doe&email=john%40example.com&age=30";
299        let form = parse_form_map(body).unwrap();
300
301        assert_eq!(form.get("name"), Some(&"John Doe".to_string()));
302        assert_eq!(form.get("email"), Some(&"john@example.com".to_string()));
303        assert_eq!(form.get("age"), Some(&"30".to_string()));
304    }
305
306    #[test]
307    fn test_form_file_extension() {
308        let file = FormFile::new(
309            "document.pdf".to_string(),
310            "application/pdf".to_string(),
311            vec![1, 2, 3],
312        );
313
314        assert_eq!(file.extension(), Some("pdf"));
315    }
316
317    #[test]
318    fn test_form_file_is_image() {
319        let image = FormFile::new("photo.jpg".to_string(), "image/jpeg".to_string(), vec![]);
320        assert!(image.is_image());
321
322        let doc = FormFile::new("doc.pdf".to_string(), "application/pdf".to_string(), vec![]);
323        assert!(!doc.is_image());
324    }
325
326    #[test]
327    fn test_form_file_size_check() {
328        let file = FormFile::new(
329            "file.txt".to_string(),
330            "text/plain".to_string(),
331            vec![0; 1024], // 1KB
332        );
333
334        assert!(!file.exceeds_size(2048)); // 2KB limit
335        assert!(file.exceeds_size(512)); // 512B limit
336    }
337
338    #[test]
339    fn test_multipart_parser_from_content_type() {
340        let content_type = "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW";
341        let parser = MultipartParser::from_content_type(content_type).unwrap();
342
343        assert_eq!(parser.boundary, "----WebKitFormBoundary7MA4YWxkTrZu0gW");
344    }
345
346    #[test]
347    fn test_multipart_parser_missing_boundary() {
348        let content_type = "multipart/form-data";
349        let result = MultipartParser::from_content_type(content_type);
350
351        assert!(result.is_err());
352    }
353}