Skip to main content

libdd_common/
multipart.rs

1// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3
4/// A single part in a multipart form-data payload.
5#[derive(Debug, Clone)]
6pub struct MultipartPart {
7    /// The field name for this part.
8    pub name: String,
9    /// The part's data.
10    pub data: Vec<u8>,
11    /// Optional filename for this part.
12    pub filename: Option<String>,
13    /// Optional MIME content type (e.g. `"application/json"`).
14    pub content_type: Option<String>,
15}
16
17impl MultipartPart {
18    /// Create a new multipart part with the given field name and data.
19    pub fn new(name: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
20        Self {
21            name: name.into(),
22            data: data.into(),
23            filename: None,
24            content_type: None,
25        }
26    }
27
28    /// Set the filename for this part.
29    pub fn filename(mut self, filename: impl Into<String>) -> Self {
30        self.filename = Some(filename.into());
31        self
32    }
33
34    /// Set the MIME content type for this part.
35    pub fn content_type(mut self, content_type: impl Into<String>) -> Self {
36        self.content_type = Some(content_type.into());
37        self
38    }
39}
40
41const BOUNDARY: &str = "------------------------dd_multipart_boundary";
42
43/// Encoded multipart form-data payload ready for use as an HTTP request body.
44#[derive(Debug)]
45pub struct MultipartFormData {
46    body: Vec<u8>,
47}
48
49impl MultipartFormData {
50    /// Encode the given parts into a multipart form-data payload.
51    pub fn encode(parts: Vec<MultipartPart>) -> Self {
52        let mut body = Vec::new();
53
54        for part in parts {
55            body.extend_from_slice(b"--");
56            body.extend_from_slice(BOUNDARY.as_bytes());
57            body.extend_from_slice(b"\r\n");
58
59            // Content-Disposition header
60            body.extend_from_slice(b"Content-Disposition: form-data; name=\"");
61            body.extend_from_slice(part.name.as_bytes());
62            body.extend_from_slice(b"\"");
63            if let Some(filename) = &part.filename {
64                body.extend_from_slice(b"; filename=\"");
65                body.extend_from_slice(filename.as_bytes());
66                body.extend_from_slice(b"\"");
67            }
68            body.extend_from_slice(b"\r\n");
69
70            // Content-Type header (if specified)
71            if let Some(ct) = &part.content_type {
72                body.extend_from_slice(b"Content-Type: ");
73                body.extend_from_slice(ct.as_bytes());
74                body.extend_from_slice(b"\r\n");
75            }
76
77            // Blank line separating headers from body
78            body.extend_from_slice(b"\r\n");
79
80            // Part data
81            body.extend_from_slice(&part.data);
82            body.extend_from_slice(b"\r\n");
83        }
84
85        // Final boundary
86        body.extend_from_slice(b"--");
87        body.extend_from_slice(BOUNDARY.as_bytes());
88        body.extend_from_slice(b"--\r\n");
89
90        Self { body }
91    }
92
93    /// The Content-Type header value for this multipart payload.
94    pub fn content_type(&self) -> String {
95        format!("multipart/form-data; boundary={BOUNDARY}")
96    }
97
98    /// Consume this payload and return the encoded body bytes.
99    pub fn into_body(self) -> Vec<u8> {
100        self.body
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn encode_single_text_field() {
110        let form = MultipartFormData::encode(vec![MultipartPart::new("field", b"value".to_vec())]);
111
112        let body = String::from_utf8(form.into_body()).unwrap();
113        assert!(body.contains("Content-Disposition: form-data; name=\"field\""));
114        assert!(body.contains("value"));
115        assert!(body.contains(&format!("--{BOUNDARY}--")));
116    }
117
118    #[test]
119    fn encode_with_filename_and_content_type() {
120        let form = MultipartFormData::encode(vec![MultipartPart::new("file", b"data".to_vec())
121            .filename("test.bin")
122            .content_type("application/octet-stream")]);
123
124        let body = String::from_utf8(form.into_body()).unwrap();
125        assert!(body.contains("filename=\"test.bin\""));
126        assert!(body.contains("Content-Type: application/octet-stream"));
127    }
128
129    #[test]
130    fn encode_multiple_parts() {
131        let form = MultipartFormData::encode(vec![
132            MultipartPart::new("metadata", br#"{"id":"123"}"#.to_vec())
133                .content_type("application/json"),
134            MultipartPart::new("file", vec![0xDE, 0xAD, 0xBE, 0xEF])
135                .filename("data.bin")
136                .content_type("application/octet-stream"),
137        ]);
138
139        let body = form.into_body();
140        let body_str = String::from_utf8_lossy(&body);
141
142        // Both parts present
143        assert!(body_str.contains("name=\"metadata\""));
144        assert!(body_str.contains("name=\"file\""));
145        assert!(body_str.contains("filename=\"data.bin\""));
146    }
147
148    #[test]
149    fn content_type_includes_boundary() {
150        let form = MultipartFormData::encode(vec![]);
151        assert_eq!(
152            form.content_type(),
153            format!("multipart/form-data; boundary={BOUNDARY}")
154        );
155    }
156}