we (web engine): Experimental web browser project to understand the limits of Claude
1//! High-level HTTP/1.1 client with connection pooling.
2//!
3//! Brings together TCP, TLS 1.3, DNS, URL parsing, and HTTP message
4//! parsing into a single `HttpClient` that can fetch HTTP and HTTPS URLs.
5
6use std::collections::HashMap;
7use std::fmt;
8use std::io;
9use std::time::{Duration, Instant};
10
11use we_url::Url;
12
13use crate::http::{self, Headers, HttpResponse, Method};
14use crate::tcp::{self, TcpConnection};
15use crate::tls::handshake::{self, HandshakeError, TlsStream};
16
17// ---------------------------------------------------------------------------
18// Constants
19// ---------------------------------------------------------------------------
20
21const DEFAULT_MAX_REDIRECTS: u32 = 10;
22const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
23const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(30);
24const DEFAULT_MAX_IDLE_TIME: Duration = Duration::from_secs(60);
25const DEFAULT_MAX_PER_HOST: usize = 6;
26const READ_BUF_SIZE: usize = 8192;
27
28// ---------------------------------------------------------------------------
29// Error type
30// ---------------------------------------------------------------------------
31
32/// Errors that can occur during an HTTP client operation.
33#[derive(Debug)]
34pub enum ClientError {
35 /// URL is invalid or missing required components.
36 InvalidUrl(String),
37 /// Unsupported URL scheme.
38 UnsupportedScheme(String),
39 /// TCP connection error.
40 Tcp(tcp::NetError),
41 /// TLS handshake error.
42 Tls(HandshakeError),
43 /// HTTP parsing error.
44 Http(http::HttpError),
45 /// Too many redirects.
46 TooManyRedirects,
47 /// Connection was closed unexpectedly.
48 ConnectionClosed,
49 /// I/O error.
50 Io(io::Error),
51}
52
53impl fmt::Display for ClientError {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 match self {
56 Self::InvalidUrl(s) => write!(f, "invalid URL: {s}"),
57 Self::UnsupportedScheme(s) => write!(f, "unsupported scheme: {s}"),
58 Self::Tcp(e) => write!(f, "TCP error: {e}"),
59 Self::Tls(e) => write!(f, "TLS error: {e}"),
60 Self::Http(e) => write!(f, "HTTP error: {e}"),
61 Self::TooManyRedirects => write!(f, "too many redirects"),
62 Self::ConnectionClosed => write!(f, "connection closed"),
63 Self::Io(e) => write!(f, "I/O error: {e}"),
64 }
65 }
66}
67
68impl From<tcp::NetError> for ClientError {
69 fn from(e: tcp::NetError) -> Self {
70 Self::Tcp(e)
71 }
72}
73
74impl From<HandshakeError> for ClientError {
75 fn from(e: HandshakeError) -> Self {
76 Self::Tls(e)
77 }
78}
79
80impl From<http::HttpError> for ClientError {
81 fn from(e: http::HttpError) -> Self {
82 Self::Http(e)
83 }
84}
85
86impl From<io::Error> for ClientError {
87 fn from(e: io::Error) -> Self {
88 Self::Io(e)
89 }
90}
91
92pub type Result<T> = std::result::Result<T, ClientError>;
93
94// ---------------------------------------------------------------------------
95// Connection abstraction
96// ---------------------------------------------------------------------------
97
98/// A connection that can be either plain TCP or TLS-encrypted.
99enum Connection {
100 Plain(TcpConnection),
101 Tls(TlsStream<TcpConnection>),
102}
103
104impl Connection {
105 fn read(&mut self, buf: &mut [u8]) -> Result<usize> {
106 match self {
107 Self::Plain(tcp) => tcp.read(buf).map_err(ClientError::Tcp),
108 Self::Tls(tls) => tls.read(buf).map_err(ClientError::Tls),
109 }
110 }
111
112 fn write_all(&mut self, data: &[u8]) -> Result<()> {
113 match self {
114 Self::Plain(tcp) => tcp.write_all(data).map_err(ClientError::Tcp),
115 Self::Tls(tls) => tls.write_all(data).map_err(ClientError::Tls),
116 }
117 }
118
119 fn flush(&mut self) -> Result<()> {
120 match self {
121 Self::Plain(tcp) => tcp.flush().map_err(ClientError::Tcp),
122 Self::Tls(_) => Ok(()), // TLS writes are flushed per record
123 }
124 }
125
126 fn set_read_timeout(&self, duration: Option<Duration>) -> Result<()> {
127 match self {
128 Self::Plain(tcp) => tcp.set_read_timeout(duration).map_err(ClientError::Tcp),
129 Self::Tls(tls) => tls
130 .stream()
131 .set_read_timeout(duration)
132 .map_err(ClientError::Tcp),
133 }
134 }
135}
136
137// ---------------------------------------------------------------------------
138// Connection pool
139// ---------------------------------------------------------------------------
140
141/// Key for pooling connections by origin.
142#[derive(Hash, Eq, PartialEq, Clone, Debug)]
143struct ConnectionKey {
144 host: String,
145 port: u16,
146 is_tls: bool,
147}
148
149/// A pooled connection with its idle timestamp.
150struct PooledConnection {
151 conn: Connection,
152 idle_since: Instant,
153}
154
155/// Pool of idle HTTP connections for reuse.
156struct ConnectionPool {
157 connections: HashMap<ConnectionKey, Vec<PooledConnection>>,
158 max_idle_time: Duration,
159 max_per_host: usize,
160}
161
162impl ConnectionPool {
163 fn new(max_idle_time: Duration, max_per_host: usize) -> Self {
164 Self {
165 connections: HashMap::new(),
166 max_idle_time,
167 max_per_host,
168 }
169 }
170
171 /// Take an idle connection for the given key, if one is available.
172 fn take(&mut self, key: &ConnectionKey) -> Option<Connection> {
173 let entries = self.connections.get_mut(key)?;
174 let now = Instant::now();
175
176 // Remove expired connections
177 entries.retain(|pc| now.duration_since(pc.idle_since) < self.max_idle_time);
178
179 // Take the most recently idled connection
180 entries.pop().map(|pc| pc.conn)
181 }
182
183 /// Return a connection to the pool.
184 fn put(&mut self, key: ConnectionKey, conn: Connection) {
185 let entries = self.connections.entry(key).or_default();
186
187 // Evict oldest if at capacity
188 if entries.len() >= self.max_per_host {
189 entries.remove(0);
190 }
191
192 entries.push(PooledConnection {
193 conn,
194 idle_since: Instant::now(),
195 });
196 }
197}
198
199// ---------------------------------------------------------------------------
200// HttpClient
201// ---------------------------------------------------------------------------
202
203/// High-level HTTP/1.1 client with connection pooling and redirect following.
204pub struct HttpClient {
205 pool: ConnectionPool,
206 max_redirects: u32,
207 connect_timeout: Duration,
208 read_timeout: Duration,
209}
210
211impl HttpClient {
212 /// Create a new HTTP client with default settings.
213 pub fn new() -> Self {
214 Self {
215 pool: ConnectionPool::new(DEFAULT_MAX_IDLE_TIME, DEFAULT_MAX_PER_HOST),
216 max_redirects: DEFAULT_MAX_REDIRECTS,
217 connect_timeout: DEFAULT_CONNECT_TIMEOUT,
218 read_timeout: DEFAULT_READ_TIMEOUT,
219 }
220 }
221
222 /// Set the maximum number of redirects to follow.
223 pub fn set_max_redirects(&mut self, max: u32) {
224 self.max_redirects = max;
225 }
226
227 /// Set the connection timeout.
228 pub fn set_connect_timeout(&mut self, timeout: Duration) {
229 self.connect_timeout = timeout;
230 }
231
232 /// Set the read timeout.
233 pub fn set_read_timeout(&mut self, timeout: Duration) {
234 self.read_timeout = timeout;
235 }
236
237 /// Perform an HTTP GET request.
238 pub fn get(&mut self, url: &Url) -> Result<HttpResponse> {
239 self.request(Method::Get, url, &Headers::new(), None)
240 }
241
242 /// Perform an HTTP POST request.
243 pub fn post(&mut self, url: &Url, body: &[u8], content_type: &str) -> Result<HttpResponse> {
244 let mut headers = Headers::new();
245 headers.add("Content-Type", content_type);
246 self.request(Method::Post, url, &headers, Some(body))
247 }
248
249 /// Perform an HTTP request with full control over method, headers, and body.
250 ///
251 /// Follows redirects (301, 302, 307, 308) up to `max_redirects`.
252 pub fn request(
253 &mut self,
254 method: Method,
255 url: &Url,
256 headers: &Headers,
257 body: Option<&[u8]>,
258 ) -> Result<HttpResponse> {
259 let mut current_url = url.clone();
260 let mut redirects = 0;
261
262 loop {
263 let resp = self.execute_request(method, ¤t_url, headers, body)?;
264
265 // Check for redirects
266 if matches!(resp.status_code, 301 | 302 | 307 | 308) {
267 redirects += 1;
268 if redirects > self.max_redirects {
269 return Err(ClientError::TooManyRedirects);
270 }
271
272 if let Some(location) = resp.headers.get("Location") {
273 // Resolve relative URLs against current URL
274 current_url = Url::parse_with_base(location, ¤t_url)
275 .or_else(|_| Url::parse(location))
276 .map_err(|_| {
277 ClientError::InvalidUrl(format!(
278 "invalid redirect location: {location}"
279 ))
280 })?;
281 continue;
282 }
283 }
284
285 return Ok(resp);
286 }
287 }
288
289 /// Execute a single HTTP request (no redirect following).
290 fn execute_request(
291 &mut self,
292 method: Method,
293 url: &Url,
294 headers: &Headers,
295 body: Option<&[u8]>,
296 ) -> Result<HttpResponse> {
297 let scheme = url.scheme();
298 let is_tls = match scheme {
299 "https" => true,
300 "http" => false,
301 other => return Err(ClientError::UnsupportedScheme(other.to_string())),
302 };
303
304 let host = url
305 .host_str()
306 .ok_or_else(|| ClientError::InvalidUrl("missing host".to_string()))?;
307
308 let port = url
309 .port_or_default()
310 .ok_or_else(|| ClientError::InvalidUrl("cannot determine port".to_string()))?;
311
312 let path = request_path(url);
313
314 let key = ConnectionKey {
315 host: host.clone(),
316 port,
317 is_tls,
318 };
319
320 // Try to reuse a pooled connection, fall back to new connection
321 let mut conn = match self.pool.take(&key) {
322 Some(conn) => conn,
323 None => self.connect(&host, port, is_tls)?,
324 };
325
326 conn.set_read_timeout(Some(self.read_timeout))?;
327
328 // Serialize and send request
329 let request_bytes = http::serialize_request(method, &path, &host, headers, body);
330 conn.write_all(&request_bytes)?;
331 conn.flush()?;
332
333 // Read and parse response
334 let response = read_response(&mut conn)?;
335
336 // Return connection to pool if keep-alive
337 if !response.connection_close() {
338 self.pool.put(key, conn);
339 }
340
341 Ok(response)
342 }
343
344 /// Establish a new connection (plain TCP or TLS).
345 fn connect(&self, host: &str, port: u16, is_tls: bool) -> Result<Connection> {
346 let tcp = TcpConnection::connect_timeout(host, port, self.connect_timeout)?;
347
348 if is_tls {
349 let tls = handshake::connect(tcp, host)?;
350 Ok(Connection::Tls(tls))
351 } else {
352 Ok(Connection::Plain(tcp))
353 }
354 }
355}
356
357impl Default for HttpClient {
358 fn default() -> Self {
359 Self::new()
360 }
361}
362
363// ---------------------------------------------------------------------------
364// Helpers
365// ---------------------------------------------------------------------------
366
367/// Build the request path from a URL (path + query).
368fn request_path(url: &Url) -> String {
369 let path = url.path();
370 let path = if path.is_empty() { "/" } else { &path };
371 match url.query() {
372 Some(q) => format!("{path}?{q}"),
373 None => path.to_string(),
374 }
375}
376
377/// Read a complete HTTP response from a connection.
378///
379/// Reads the header section first, then determines the body length from
380/// headers and reads the appropriate amount of body data.
381fn read_response(conn: &mut Connection) -> Result<HttpResponse> {
382 let mut buf = Vec::with_capacity(READ_BUF_SIZE);
383 let mut temp = [0u8; READ_BUF_SIZE];
384
385 // Phase 1: Read until we have the complete header section (\r\n\r\n)
386 let header_end = loop {
387 let n = conn.read(&mut temp)?;
388 if n == 0 {
389 if buf.is_empty() {
390 return Err(ClientError::ConnectionClosed);
391 }
392 break find_header_end(&buf);
393 }
394 buf.extend_from_slice(&temp[..n]);
395 if let Some(pos) = find_header_end(&buf) {
396 break Some(pos);
397 }
398 };
399
400 let header_end = header_end.ok_or(ClientError::Http(http::HttpError::Incomplete))?;
401 let body_start = header_end + 4; // skip \r\n\r\n
402
403 // Quick-parse headers to determine body strategy
404 let header_str = std::str::from_utf8(&buf[..header_end]).map_err(|_| {
405 ClientError::Http(http::HttpError::Parse(
406 "invalid UTF-8 in headers".to_string(),
407 ))
408 })?;
409
410 let status_code = parse_status_code(header_str)?;
411 let body_strategy = determine_body_strategy(header_str, status_code);
412
413 // Phase 2: Read body according to strategy
414 match body_strategy {
415 BodyStrategy::NoBody => {
416 // Truncate buffer to just headers + \r\n\r\n
417 buf.truncate(body_start);
418 }
419 BodyStrategy::ContentLength(len) => {
420 let total_needed = body_start + len;
421 while buf.len() < total_needed {
422 let n = conn.read(&mut temp)?;
423 if n == 0 {
424 break;
425 }
426 buf.extend_from_slice(&temp[..n]);
427 }
428 }
429 BodyStrategy::Chunked => {
430 // Read until we find the terminating 0-length chunk
431 while !has_chunked_terminator(&buf[body_start..]) {
432 let n = conn.read(&mut temp)?;
433 if n == 0 {
434 break;
435 }
436 buf.extend_from_slice(&temp[..n]);
437 }
438 }
439 BodyStrategy::ReadUntilClose => {
440 // Read until EOF
441 loop {
442 let n = conn.read(&mut temp)?;
443 if n == 0 {
444 break;
445 }
446 buf.extend_from_slice(&temp[..n]);
447 }
448 }
449 }
450
451 // Parse the complete response
452 http::parse_response(&buf).map_err(ClientError::Http)
453}
454
455/// Find the end of the HTTP header section (\r\n\r\n).
456/// Returns the position of the first \r in the \r\n\r\n sequence.
457fn find_header_end(data: &[u8]) -> Option<usize> {
458 data.windows(4).position(|w| w == b"\r\n\r\n")
459}
460
461/// Extract status code from the first line of headers.
462fn parse_status_code(headers: &str) -> Result<u16> {
463 let first_line = headers.lines().next().unwrap_or("");
464 let mut parts = first_line.splitn(3, ' ');
465 let _version = parts.next();
466 let code_str = parts.next().unwrap_or("");
467 code_str.parse().map_err(|_| {
468 ClientError::Http(http::HttpError::MalformedStatusLine(first_line.to_string()))
469 })
470}
471
472/// Strategy for reading the response body.
473enum BodyStrategy {
474 NoBody,
475 ContentLength(usize),
476 Chunked,
477 ReadUntilClose,
478}
479
480/// Extract the value for a header name (case-insensitive match).
481fn header_value<'a>(line: &'a str, name: &str) -> Option<&'a str> {
482 let colon = line.find(':')?;
483 if line[..colon].eq_ignore_ascii_case(name) {
484 Some(line[colon + 1..].trim())
485 } else {
486 None
487 }
488}
489
490/// Determine how to read the body from headers.
491fn determine_body_strategy(headers: &str, status_code: u16) -> BodyStrategy {
492 // 1xx, 204, 304 have no body
493 if status_code < 200 || status_code == 204 || status_code == 304 {
494 return BodyStrategy::NoBody;
495 }
496
497 // Check for Transfer-Encoding: chunked
498 for line in headers.split("\r\n").skip(1) {
499 if let Some(val) = header_value(line, "transfer-encoding") {
500 if val.eq_ignore_ascii_case("chunked") {
501 return BodyStrategy::Chunked;
502 }
503 }
504 }
505
506 // Check for Content-Length
507 for line in headers.split("\r\n").skip(1) {
508 if let Some(val) = header_value(line, "content-length") {
509 if let Ok(len) = val.parse::<usize>() {
510 return BodyStrategy::ContentLength(len);
511 }
512 }
513 }
514
515 BodyStrategy::ReadUntilClose
516}
517
518/// Check if chunked body data contains the terminating `0\r\n\r\n`.
519fn has_chunked_terminator(data: &[u8]) -> bool {
520 // Look for \r\n0\r\n\r\n (the final chunk after some data) or 0\r\n\r\n at start
521 data.windows(5).any(|w| w == b"0\r\n\r\n")
522}
523
524// ---------------------------------------------------------------------------
525// Tests
526// ---------------------------------------------------------------------------
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 // -- ClientError Display tests --
533
534 #[test]
535 fn error_display_invalid_url() {
536 let e = ClientError::InvalidUrl("bad".to_string());
537 assert_eq!(e.to_string(), "invalid URL: bad");
538 }
539
540 #[test]
541 fn error_display_unsupported_scheme() {
542 let e = ClientError::UnsupportedScheme("ftp".to_string());
543 assert_eq!(e.to_string(), "unsupported scheme: ftp");
544 }
545
546 #[test]
547 fn error_display_too_many_redirects() {
548 let e = ClientError::TooManyRedirects;
549 assert_eq!(e.to_string(), "too many redirects");
550 }
551
552 #[test]
553 fn error_display_connection_closed() {
554 let e = ClientError::ConnectionClosed;
555 assert_eq!(e.to_string(), "connection closed");
556 }
557
558 // -- HttpClient configuration tests --
559
560 #[test]
561 fn client_default() {
562 let client = HttpClient::default();
563 assert_eq!(client.max_redirects, DEFAULT_MAX_REDIRECTS);
564 assert_eq!(client.connect_timeout, DEFAULT_CONNECT_TIMEOUT);
565 assert_eq!(client.read_timeout, DEFAULT_READ_TIMEOUT);
566 }
567
568 #[test]
569 fn client_set_max_redirects() {
570 let mut client = HttpClient::new();
571 client.set_max_redirects(5);
572 assert_eq!(client.max_redirects, 5);
573 }
574
575 #[test]
576 fn client_set_connect_timeout() {
577 let mut client = HttpClient::new();
578 client.set_connect_timeout(Duration::from_secs(10));
579 assert_eq!(client.connect_timeout, Duration::from_secs(10));
580 }
581
582 #[test]
583 fn client_set_read_timeout() {
584 let mut client = HttpClient::new();
585 client.set_read_timeout(Duration::from_secs(5));
586 assert_eq!(client.read_timeout, Duration::from_secs(5));
587 }
588
589 // -- ConnectionPool tests --
590
591 #[test]
592 fn pool_take_empty() {
593 let mut pool = ConnectionPool::new(Duration::from_secs(60), 6);
594 let key = ConnectionKey {
595 host: "example.com".to_string(),
596 port: 80,
597 is_tls: false,
598 };
599 assert!(pool.take(&key).is_none());
600 }
601
602 #[test]
603 fn pool_connections_map_starts_empty() {
604 let pool = ConnectionPool::new(Duration::from_secs(60), 6);
605 assert!(pool.connections.is_empty());
606 }
607
608 // -- request_path tests --
609
610 #[test]
611 fn request_path_simple() {
612 let url = Url::parse("http://example.com/path").unwrap();
613 assert_eq!(request_path(&url), "/path");
614 }
615
616 #[test]
617 fn request_path_with_query() {
618 let url = Url::parse("http://example.com/path?key=value").unwrap();
619 assert_eq!(request_path(&url), "/path?key=value");
620 }
621
622 #[test]
623 fn request_path_root() {
624 let url = Url::parse("http://example.com").unwrap();
625 assert_eq!(request_path(&url), "/");
626 }
627
628 #[test]
629 fn request_path_deep() {
630 let url = Url::parse("http://example.com/a/b/c").unwrap();
631 assert_eq!(request_path(&url), "/a/b/c");
632 }
633
634 // -- find_header_end tests --
635
636 #[test]
637 fn find_header_end_found() {
638 let data = b"HTTP/1.1 200 OK\r\nHost: x\r\n\r\nbody";
639 assert_eq!(find_header_end(data), Some(24));
640 }
641
642 #[test]
643 fn find_header_end_not_found() {
644 let data = b"HTTP/1.1 200 OK\r\nHost: x\r\n";
645 assert_eq!(find_header_end(data), None);
646 }
647
648 #[test]
649 fn find_header_end_empty() {
650 assert_eq!(find_header_end(b""), None);
651 }
652
653 #[test]
654 fn find_header_end_minimal() {
655 let data = b"\r\n\r\n";
656 assert_eq!(find_header_end(data), Some(0));
657 }
658
659 // -- parse_status_code tests --
660
661 #[test]
662 fn parse_status_code_200() {
663 assert_eq!(parse_status_code("HTTP/1.1 200 OK").unwrap(), 200);
664 }
665
666 #[test]
667 fn parse_status_code_404() {
668 assert_eq!(parse_status_code("HTTP/1.1 404 Not Found").unwrap(), 404);
669 }
670
671 #[test]
672 fn parse_status_code_301() {
673 assert_eq!(
674 parse_status_code("HTTP/1.1 301 Moved Permanently").unwrap(),
675 301
676 );
677 }
678
679 #[test]
680 fn parse_status_code_invalid() {
681 assert!(parse_status_code("INVALID").is_err());
682 }
683
684 // -- determine_body_strategy tests --
685
686 #[test]
687 fn strategy_no_body_204() {
688 let headers = "HTTP/1.1 204 No Content\r\nConnection: keep-alive";
689 assert!(matches!(
690 determine_body_strategy(headers, 204),
691 BodyStrategy::NoBody
692 ));
693 }
694
695 #[test]
696 fn strategy_no_body_304() {
697 let headers = "HTTP/1.1 304 Not Modified\r\nETag: \"abc\"";
698 assert!(matches!(
699 determine_body_strategy(headers, 304),
700 BodyStrategy::NoBody
701 ));
702 }
703
704 #[test]
705 fn strategy_no_body_1xx() {
706 let headers = "HTTP/1.1 100 Continue";
707 assert!(matches!(
708 determine_body_strategy(headers, 100),
709 BodyStrategy::NoBody
710 ));
711 }
712
713 #[test]
714 fn strategy_content_length() {
715 let headers = "HTTP/1.1 200 OK\r\nContent-Length: 42";
716 match determine_body_strategy(headers, 200) {
717 BodyStrategy::ContentLength(42) => {}
718 _ => panic!("expected ContentLength(42)"),
719 }
720 }
721
722 #[test]
723 fn strategy_chunked() {
724 let headers = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked";
725 assert!(matches!(
726 determine_body_strategy(headers, 200),
727 BodyStrategy::Chunked
728 ));
729 }
730
731 #[test]
732 fn strategy_read_until_close() {
733 let headers = "HTTP/1.1 200 OK\r\nConnection: close";
734 assert!(matches!(
735 determine_body_strategy(headers, 200),
736 BodyStrategy::ReadUntilClose
737 ));
738 }
739
740 // -- has_chunked_terminator tests --
741
742 #[test]
743 fn chunked_terminator_present() {
744 assert!(has_chunked_terminator(b"5\r\nHello\r\n0\r\n\r\n"));
745 }
746
747 #[test]
748 fn chunked_terminator_at_start() {
749 assert!(has_chunked_terminator(b"0\r\n\r\n"));
750 }
751
752 #[test]
753 fn chunked_terminator_missing() {
754 assert!(!has_chunked_terminator(b"5\r\nHello\r\n"));
755 }
756
757 #[test]
758 fn chunked_terminator_empty() {
759 assert!(!has_chunked_terminator(b""));
760 }
761
762 // -- ConnectionKey equality tests --
763
764 #[test]
765 fn connection_key_equal() {
766 let a = ConnectionKey {
767 host: "example.com".to_string(),
768 port: 443,
769 is_tls: true,
770 };
771 let b = ConnectionKey {
772 host: "example.com".to_string(),
773 port: 443,
774 is_tls: true,
775 };
776 assert_eq!(a, b);
777 }
778
779 #[test]
780 fn connection_key_different_host() {
781 let a = ConnectionKey {
782 host: "a.com".to_string(),
783 port: 443,
784 is_tls: true,
785 };
786 let b = ConnectionKey {
787 host: "b.com".to_string(),
788 port: 443,
789 is_tls: true,
790 };
791 assert_ne!(a, b);
792 }
793
794 #[test]
795 fn connection_key_different_port() {
796 let a = ConnectionKey {
797 host: "example.com".to_string(),
798 port: 80,
799 is_tls: false,
800 };
801 let b = ConnectionKey {
802 host: "example.com".to_string(),
803 port: 8080,
804 is_tls: false,
805 };
806 assert_ne!(a, b);
807 }
808
809 #[test]
810 fn connection_key_different_tls() {
811 let a = ConnectionKey {
812 host: "example.com".to_string(),
813 port: 443,
814 is_tls: true,
815 };
816 let b = ConnectionKey {
817 host: "example.com".to_string(),
818 port: 443,
819 is_tls: false,
820 };
821 assert_ne!(a, b);
822 }
823
824 // -- Header parsing strategy with case variations --
825
826 #[test]
827 fn strategy_content_length_lowercase() {
828 let headers = "HTTP/1.1 200 OK\r\ncontent-length: 10";
829 match determine_body_strategy(headers, 200) {
830 BodyStrategy::ContentLength(10) => {}
831 _ => panic!("expected ContentLength(10)"),
832 }
833 }
834
835 #[test]
836 fn strategy_chunked_lowercase() {
837 let headers = "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked";
838 assert!(matches!(
839 determine_body_strategy(headers, 200),
840 BodyStrategy::Chunked
841 ));
842 }
843
844 #[test]
845 fn strategy_chunked_uppercase_value() {
846 let headers = "HTTP/1.1 200 OK\r\nTransfer-Encoding: CHUNKED";
847 assert!(matches!(
848 determine_body_strategy(headers, 200),
849 BodyStrategy::Chunked
850 ));
851 }
852
853 #[test]
854 fn strategy_content_length_mixed_case() {
855 let headers = "HTTP/1.1 200 OK\r\nCONTENT-LENGTH: 99";
856 match determine_body_strategy(headers, 200) {
857 BodyStrategy::ContentLength(99) => {}
858 _ => panic!("expected ContentLength(99)"),
859 }
860 }
861
862 #[test]
863 fn strategy_chunked_mixed_case_name() {
864 let headers = "HTTP/1.1 200 OK\r\nTRANSFER-ENCODING: chunked";
865 assert!(matches!(
866 determine_body_strategy(headers, 200),
867 BodyStrategy::Chunked
868 ));
869 }
870
871 // -- URL scheme handling --
872
873 #[test]
874 fn unsupported_scheme_error() {
875 let mut client = HttpClient::new();
876 let url = Url::parse("ftp://example.com/file").unwrap();
877 let result = client.get(&url);
878 assert!(matches!(result, Err(ClientError::UnsupportedScheme(_))));
879 }
880}