Skip to main content

http_stat/
http_request.rs

1// Copyright 2025 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// This file implements HTTP request functionality with support for HTTP/1.1, HTTP/2, and HTTP/3
16// It includes features like DNS resolution, TLS handshake, and request/response handling
17
18use super::error::{Error, Result};
19use super::stats::{HttpStat, ALPN_HTTP1, ALPN_HTTP2};
20use bytes::Bytes;
21use http::request::Builder;
22use http::HeaderValue;
23use http::Request;
24use http::Uri;
25use http::{HeaderMap, Method};
26use http_body_util::Full;
27use rustls::client::{ClientSessionMemoryCache, ClientSessionStore};
28use std::net::IpAddr;
29use std::str::FromStr;
30use std::sync::Arc;
31use std::time::Duration;
32use std::time::Instant;
33
34// Version information from Cargo.toml
35const VERSION: &str = env!("CARGO_PKG_VERSION");
36
37// Handle request error and update statistics
38pub(crate) fn finish_with_error(
39    mut stat: HttpStat,
40    error: impl ToString,
41    start: Instant,
42) -> HttpStat {
43    stat.error = Some(error.to_string());
44    stat.total = Some(start.elapsed());
45    stat
46}
47
48/// A single `--connect-to HOST1:PORT1:HOST2:PORT2` entry.
49///
50/// When the request target matches `(src_host, src_port)`, the TCP connection is
51/// established to `(dst_host, dst_port)`. TLS SNI and the HTTP `Host` header still
52/// use the original hostname — only the actual TCP destination changes.
53///
54/// Empty `src_host` / `src_port` act as wildcards. Empty `dst_host` / absent `dst_port`
55/// keep the original value.
56#[derive(Debug, Clone)]
57pub struct ConnectTo {
58    src_host: String,
59    src_port: Option<u16>,
60    pub dst_host: String,
61    pub dst_port: Option<u16>,
62}
63
64fn parse_host_segment(s: &str) -> (String, &str) {
65    if let Some(rest) = s.strip_prefix('[') {
66        // IPv6 bracketed: [addr]...
67        if let Some(end) = rest.find(']') {
68            return (rest[..end].to_string(), &rest[end + 1..]);
69        }
70    }
71    // Plain host: take up to first ':'
72    let colon = s.find(':').unwrap_or(s.len());
73    (s[..colon].to_string(), &s[colon..])
74}
75
76impl ConnectTo {
77    /// Parse `HOST1:PORT1:HOST2:PORT2`. Any field may be empty; IPv6 uses `[addr]`.
78    pub fn parse(s: &str) -> Option<Self> {
79        let (src_host, rest) = parse_host_segment(s);
80        let rest = rest.strip_prefix(':')?; // require separator after HOST1
81
82        // PORT1 up to next ':'
83        let colon = rest.find(':')?;
84        let src_port = if rest[..colon].is_empty() {
85            None
86        } else {
87            Some(rest[..colon].parse().ok()?)
88        };
89        let rest = &rest[colon + 1..];
90
91        // HOST2
92        let (dst_host, rest) = parse_host_segment(rest);
93
94        // Optional ':PORT2'
95        let port2_str = rest.strip_prefix(':').unwrap_or(rest);
96        let dst_port = if port2_str.is_empty() {
97            None
98        } else {
99            Some(port2_str.parse().ok()?)
100        };
101
102        Some(ConnectTo {
103            src_host,
104            src_port,
105            dst_host,
106            dst_port,
107        })
108    }
109
110    /// Returns `true` if this entry applies to the given `(host, port)`.
111    pub fn matches(&self, host: &str, port: u16) -> bool {
112        let host_ok = self.src_host.is_empty() || self.src_host.eq_ignore_ascii_case(host);
113        let port_ok = self.src_port.is_none() || self.src_port == Some(port);
114        host_ok && port_ok
115    }
116}
117
118// HttpRequest struct to hold request configuration
119#[derive(Default, Debug, Clone)]
120pub struct HttpRequest {
121    pub uri: Uri,                                // Target URI
122    pub method: Option<String>,                  // HTTP method (GET, POST, etc.)
123    pub alpn_protocols: Vec<String>,             // Supported ALPN protocols
124    pub resolve: Option<IpAddr>,                 // Custom DNS resolution
125    pub headers: Option<HeaderMap<HeaderValue>>, // Custom HTTP headers
126    pub ip_version: Option<i32>,                 // IP version (4 for IPv4, 6 for IPv6)
127    pub skip_verify: bool,                       // Skip TLS certificate verification
128    pub body: Option<Bytes>,                     // Request body
129    pub dns_servers: Option<Vec<String>>,        // DNS servers
130    pub dns_timeout: Option<Duration>,           // DNS resolution timeout
131    pub tcp_timeout: Option<Duration>,           // TCP connection timeout
132    pub tls_timeout: Option<Duration>,           // TLS handshake timeout
133    pub request_timeout: Option<Duration>,       // HTTP request timeout
134    pub quic_timeout: Option<Duration>,          // QUIC connection timeout
135    pub client_cert: Option<Vec<u8>>,            // PEM-encoded client certificate (mTLS)
136    pub client_key: Option<Vec<u8>>,             // PEM-encoded client private key (mTLS)
137    pub proxy: Option<String>,                   // Proxy URL (http://, https://, socks5://)
138    pub use_absolute_uri: bool,                  // Send absolute URI (HTTP forward proxy)
139    pub connect_to: Vec<String>,                 // --connect-to HOST1:PORT1:HOST2:PORT2 overrides
140    pub bind_addr: Option<IpAddr>,               // Local source IP to bind before connecting
141    /// Optional shared TLS session store. When set, the rustls `ClientConfig`
142    /// is wired with `Resumption::store(...)` and `enable_early_data = true`,
143    /// so subsequent requests sharing this store can perform a resumed
144    /// handshake and attempt 0-RTT. Intended for the `-n` benchmark loop:
145    /// install one cache before the loop and clone it onto every request.
146    pub tls_session_store: Option<Arc<dyn ClientSessionStore>>,
147}
148
149impl HttpRequest {
150    pub fn get_port(&self) -> u16 {
151        let scheme = self.uri.scheme_str().unwrap_or("");
152        let default_port = if matches!(scheme, "https" | "grpcs") {
153            443
154        } else {
155            80
156        };
157        self.uri.port_u16().unwrap_or(default_port)
158    }
159    // Build HTTP request with proper headers
160    pub fn builder(&self, is_http1: bool) -> Builder {
161        let uri = &self.uri;
162        let method = if let Some(method) = &self.method {
163            Method::from_str(method).unwrap_or(Method::GET)
164        } else {
165            Method::GET
166        };
167        let mut builder = if is_http1 && !self.use_absolute_uri {
168            if let Some(value) = uri.path_and_query() {
169                Request::builder().uri(value.to_string())
170            } else {
171                Request::builder().uri(uri)
172            }
173        } else {
174            Request::builder().uri(uri)
175        };
176        builder = builder.method(method);
177        let mut set_host = false;
178        let mut set_user_agent = false;
179
180        // Add custom headers if provided
181        if let Some(headers) = &self.headers {
182            for (key, value) in headers.iter() {
183                builder = builder.header(key, value);
184                // HeaderName::as_str() is always canonical lower-case, so no
185                // allocation or explicit lowercasing is needed here.
186                match key.as_str() {
187                    "host" => set_host = true,
188                    "user-agent" => set_user_agent = true,
189                    _ => {}
190                }
191            }
192        }
193
194        // Set default Host header if not provided
195        if !set_host {
196            if let Some(host) = uri.host() {
197                let port = self.get_port();
198                if port != 80 && port != 443 {
199                    builder = builder.header("Host", format!("{host}:{port}"));
200                } else {
201                    builder = builder.header("Host", host);
202                }
203            }
204        }
205
206        // Set default User-Agent if not provided
207        if !set_user_agent {
208            builder = builder.header("User-Agent", format!("httpstat.rs/{VERSION}"));
209        }
210        builder
211    }
212}
213
214/// Create an in-memory TLS session store suitable for sharing across multiple
215/// requests (e.g. across a benchmark `-n` loop). Pass the returned `Arc` into
216/// `HttpRequest::tls_session_store` on every request that should be able to
217/// resume a previous TLS session.
218pub fn new_tls_session_store(capacity: usize) -> Arc<dyn ClientSessionStore> {
219    Arc::new(ClientSessionMemoryCache::new(capacity))
220}
221
222// Convert string URL to HttpRequest
223impl TryFrom<&str> for HttpRequest {
224    type Error = Error;
225
226    fn try_from(url: &str) -> Result<Self> {
227        let prefixes = ["http://", "https://", "grpc://", "grpcs://"];
228
229        let value = if prefixes.iter().any(|prefix| url.starts_with(prefix)) {
230            url.to_string()
231        } else {
232            format!("http://{url}")
233        };
234        let uri = value.parse::<Uri>().map_err(|e| Error::Uri { source: e })?;
235        Ok(Self {
236            uri,
237            alpn_protocols: vec![ALPN_HTTP2.to_string(), ALPN_HTTP1.to_string()],
238            ..Default::default()
239        })
240    }
241}
242
243// Convert HttpRequest to hyper Request
244impl TryFrom<&HttpRequest> for Request<Full<Bytes>> {
245    type Error = Error;
246    fn try_from(req: &HttpRequest) -> Result<Self> {
247        req.builder(true)
248            .body(Full::new(req.body.clone().unwrap_or_default()))
249            .map_err(|e| Error::Http { source: e })
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    // ---- parse_host_segment ----
258    #[test]
259    fn parse_host_segment_plain_and_bracketed() {
260        assert_eq!(parse_host_segment("host:443"), ("host".to_string(), ":443"));
261        assert_eq!(parse_host_segment("[::1]:443"), ("::1".to_string(), ":443"));
262        assert_eq!(parse_host_segment("host"), ("host".to_string(), ""));
263    }
264
265    // ---- ConnectTo::parse / matches ----
266    #[test]
267    fn connect_to_full_entry() {
268        let c = ConnectTo::parse("example.com:443:1.2.3.4:8443").unwrap();
269        assert_eq!(c.dst_host, "1.2.3.4");
270        assert_eq!(c.dst_port, Some(8443));
271        assert!(c.matches("example.com", 443));
272        assert!(c.matches("EXAMPLE.COM", 443)); // host match is case-insensitive
273        assert!(!c.matches("other.com", 443));
274        assert!(!c.matches("example.com", 80));
275    }
276
277    #[test]
278    fn connect_to_wildcards() {
279        // empty source host → matches any host on that port
280        let any_host = ConnectTo::parse(":443:1.2.3.4:8443").unwrap();
281        assert!(any_host.matches("whatever.com", 443));
282        assert!(!any_host.matches("whatever.com", 80));
283
284        // empty source port → matches that host on any port
285        let any_port = ConnectTo::parse("example.com::1.2.3.4:8443").unwrap();
286        assert!(any_port.matches("example.com", 1));
287        assert!(any_port.matches("example.com", 65535));
288        assert!(!any_port.matches("other.com", 443));
289    }
290
291    #[test]
292    fn connect_to_ipv6_and_optional_dst_port() {
293        let dst6 = ConnectTo::parse("example.com:443:[2001:db8::1]:8443").unwrap();
294        assert_eq!(dst6.dst_host, "2001:db8::1");
295        assert_eq!(dst6.dst_port, Some(8443));
296
297        let src6 = ConnectTo::parse("[2001:db8::1]:443:1.2.3.4:8443").unwrap();
298        assert!(src6.matches("2001:db8::1", 443));
299
300        // omitted destination port → None (keep the original)
301        let no_dst_port = ConnectTo::parse("example.com:443:1.2.3.4").unwrap();
302        assert_eq!(no_dst_port.dst_host, "1.2.3.4");
303        assert_eq!(no_dst_port.dst_port, None);
304    }
305
306    #[test]
307    fn connect_to_rejects_malformed() {
308        assert!(ConnectTo::parse("example.com").is_none()); // no ':' after host
309        assert!(ConnectTo::parse("example.com:443").is_none()); // missing destination
310        assert!(ConnectTo::parse("example.com:notaport:1.2.3.4:8443").is_none());
311        // bad src port
312    }
313
314    // ---- HttpRequest::try_from ----
315    #[test]
316    fn try_from_adds_scheme_and_default_alpn() {
317        let req = HttpRequest::try_from("example.com").unwrap();
318        assert_eq!(req.uri.scheme_str(), Some("http"));
319        assert_eq!(
320            req.alpn_protocols,
321            vec![ALPN_HTTP2.to_string(), ALPN_HTTP1.to_string()]
322        );
323
324        assert_eq!(
325            HttpRequest::try_from("https://example.com/path")
326                .unwrap()
327                .uri
328                .scheme_str(),
329            Some("https")
330        );
331        assert_eq!(
332            HttpRequest::try_from("grpc://svc:50051")
333                .unwrap()
334                .uri
335                .scheme_str(),
336            Some("grpc")
337        );
338    }
339
340    // ---- get_port ----
341    #[test]
342    fn get_port_defaults_by_scheme() {
343        assert_eq!(HttpRequest::try_from("http://x").unwrap().get_port(), 80);
344        assert_eq!(HttpRequest::try_from("https://x").unwrap().get_port(), 443);
345        assert_eq!(HttpRequest::try_from("grpcs://x").unwrap().get_port(), 443);
346        assert_eq!(
347            HttpRequest::try_from("http://x:8080").unwrap().get_port(),
348            8080
349        );
350    }
351
352    // ---- builder ----
353    #[test]
354    fn builder_sets_default_host_and_user_agent() {
355        let req = HttpRequest::try_from("http://example.com/path").unwrap();
356        let r = req.builder(true).body(()).unwrap();
357        assert_eq!(r.headers().get("host").unwrap(), "example.com");
358        assert!(r
359            .headers()
360            .get("user-agent")
361            .unwrap()
362            .to_str()
363            .unwrap()
364            .starts_with("httpstat.rs/"));
365    }
366
367    #[test]
368    fn builder_includes_nondefault_port_in_host() {
369        let req = HttpRequest::try_from("http://example.com:8080/").unwrap();
370        let r = req.builder(true).body(()).unwrap();
371        assert_eq!(r.headers().get("host").unwrap(), "example.com:8080");
372    }
373
374    #[test]
375    fn builder_respects_custom_host_header() {
376        let mut req = HttpRequest::try_from("http://example.com/").unwrap();
377        let mut hm = HeaderMap::new();
378        hm.insert(http::header::HOST, HeaderValue::from_static("custom.test"));
379        req.headers = Some(hm);
380        let r = req.builder(true).body(()).unwrap();
381        assert_eq!(r.headers().get("host").unwrap(), "custom.test");
382    }
383}