Skip to content

Commit c1327a8

Browse files
committed
add hyper backend and shared multipart encoder
Add a hyper-backend feature as a lighter alternative to reqwest-backend for consumers where binary size matters. The hyper backend uses libdd-common's Connector and a new shared multipart encoder in libdd-common. CI runs tests against both backends.
1 parent 34f1e42 commit c1327a8

10 files changed

Lines changed: 406 additions & 6 deletions

File tree

.github/workflows/test.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ jobs:
6969
run: cargo nextest run --workspace --features libdd-crashtracker/generate-unit-test-files --exclude builder --profile ci --test-threads=1 --verbose -E 'test(tracing_integration_tests::)'
7070
env:
7171
RUST_BACKTRACE: full
72+
- name: "[${{ steps.rust-version.outputs.version}}] cargo nextest run -p libdd-http-client (hyper-backend)"
73+
shell: bash
74+
run: cargo nextest run -p libdd-http-client --no-default-features --features hyper-backend,https --profile ci --verbose
75+
env:
76+
RUST_BACKTRACE: full
7277
- name: "[${{ steps.rust-version.outputs.version}}] RUSTFLAGS=\"-C prefer-dynamic\" cargo nextest run --package test_spawn_from_lib --features prefer-dynamic -E '!test(tracing_integration_tests::)'"
7378
shell: bash
7479
run: cargo nextest run --package test_spawn_from_lib --features prefer-dynamic -E '!test(tracing_integration_tests::)'

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

libdd-common/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub mod cstr;
2424
pub mod config;
2525
pub mod error;
2626
pub mod http_common;
27+
pub mod multipart;
2728
pub mod rate_limiter;
2829
pub mod tag;
2930
#[cfg(any(test, feature = "test-utils"))]

libdd-common/src/multipart.rs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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)]
6+
pub 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+
17+
impl 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+
41+
const BOUNDARY: &str = "------------------------dd_multipart_boundary";
42+
43+
/// Encoded multipart form-data payload ready for use as an HTTP request body.
44+
#[derive(Debug)]
45+
pub struct MultipartFormData {
46+
body: Vec<u8>,
47+
}
48+
49+
impl 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)]
105+
mod 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+
}

libdd-http-client/Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ bench = false
1414

1515
[features]
1616
default = ["https", "reqwest-backend"]
17-
https = ["dep:reqwest", "reqwest?/rustls"]
17+
https = ["dep:reqwest", "reqwest?/rustls", "libdd-common?/https"]
1818
reqwest-backend = ["dep:reqwest", "reqwest?/hickory-dns", "reqwest?/multipart"]
19+
hyper-backend = ["dep:libdd-common", "dep:hyper", "dep:hyper-util", "dep:http-body-util"]
1920
fips = ["dep:reqwest", "reqwest?/rustls-no-provider", "dep:rustls", "rustls?/aws-lc-rs"]
2021

2122
[dependencies]
@@ -25,6 +26,10 @@ fastrand = "2"
2526
tokio = { version = "1.23", features = ["rt", "time"] }
2627
reqwest = { version = "0.13", default-features = false, optional = true }
2728
rustls = { version = "0.23", default-features = false, optional = true }
29+
libdd-common = { version = "1.1.0", path = "../libdd-common", default-features = false, optional = true }
30+
hyper = { workspace = true, optional = true }
31+
hyper-util = { workspace = true, optional = true }
32+
http-body-util = { version = "0.1", optional = true }
2833

2934

3035
[dev-dependencies]

libdd-http-client/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,13 @@ Setting both multipart parts and a body on the same request returns an error.
110110
|---------|---------|-------------|
111111
| `https` | yes | HTTPS via rustls with aws-lc-rs |
112112
| `reqwest-backend` | yes | reqwest-based HTTP backend with hickory-dns |
113+
| `hyper-backend` | no | Lighter hyper-based backend via libdd-common (smaller binary) |
113114
| `fips` | no | FIPS-compliant TLS via rustls without default crypto provider |
114115

116+
Enable exactly one of `reqwest-backend` or `hyper-backend`. The hyper backend
117+
uses libdd-common's Connector infrastructure and produces smaller binaries at
118+
the cost of depending on libdd-common.
119+
115120
### FIPS TLS
116121

117122
When using the `fips` feature, call `init_fips_crypto()` once at startup before constructing any client:

0 commit comments

Comments
 (0)