Skip to main content

trunk_recorder_plugin/
multipart.rs

1//! A `multipart/form-data` body — how most upload services take a call — for
2//! any HTTP client:
3//!
4//! ```
5//! # use trunk_recorder_plugin::Multipart;
6//! let (body, content_type) = Multipart::new()
7//!     .text("talkgroup_num", "101")
8//!     .file("call", "101-1700000000.m4a", "audio/mp4", b"...")
9//!     .finish();
10//! // ureq:    agent.post(url).header("Content-Type", &content_type).send(&body)
11//! // reqwest: client.post(url).header("Content-Type", content_type).body(body).send()
12//! ```
13
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17pub struct Multipart {
18    boundary: String,
19    body: Vec<u8>,
20}
21
22impl Default for Multipart {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl Multipart {
29    pub fn new() -> Multipart {
30        static N: AtomicU64 = AtomicU64::new(0);
31        let t = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos() as u64);
32        let boundary = format!("----trunk-recorder-{t:x}{:x}", N.fetch_add(1, Ordering::Relaxed));
33        Multipart { boundary, body: Vec::new() }
34    }
35
36    /// A text field.
37    pub fn text(mut self, name: &str, value: impl AsRef<str>) -> Multipart {
38        self.part(&format!("Content-Disposition: form-data; name=\"{}\"", quote(name)), value.as_ref().as_bytes());
39        self
40    }
41
42    /// A file field.
43    pub fn file(mut self, name: &str, file_name: &str, content_type: &str, bytes: &[u8]) -> Multipart {
44        let head = format!("Content-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\nContent-Type: {content_type}", quote(name), quote(file_name));
45        self.part(&head, bytes);
46        self
47    }
48
49    /// (body, the request's Content-Type)
50    pub fn finish(mut self) -> (Vec<u8>, String) {
51        self.body.extend_from_slice(format!("--{}--\r\n", self.boundary).as_bytes());
52        (self.body, format!("multipart/form-data; boundary={}", self.boundary))
53    }
54
55    fn part(&mut self, head: &str, bytes: &[u8]) {
56        self.body.extend_from_slice(format!("--{}\r\n{head}\r\n\r\n", self.boundary).as_bytes());
57        self.body.extend_from_slice(bytes);
58        self.body.extend_from_slice(b"\r\n");
59    }
60}
61
62fn quote(s: &str) -> String {
63    s.replace('"', "%22").replace(['\r', '\n'], " ")
64}
65
66#[cfg(all(test, feature = "sdk"))]
67mod tests {
68    use super::*;
69    use crate::testing::Request;
70
71    #[test]
72    fn reads_back() {
73        let (body, ct) = Multipart::new().text("a", "1").file("call", "x.m4a", "audio/mp4", b"\0\x01bytes\r\n--").text("b", "two").finish();
74        let r = Request { headers: vec![("content-type".into(), ct)], body, ..Default::default() };
75        assert_eq!(r.form_field("a").unwrap(), b"1");
76        assert_eq!(r.form_field("b").unwrap(), b"two");
77        assert_eq!(r.form_field("call").unwrap(), b"\0\x01bytes\r\n--");
78        assert_eq!(r.form_file_name("call").unwrap(), "x.m4a");
79    }
80}