we (web engine): Experimental web browser project to understand the limits of Claude
at utf-codecs 880 lines 26 kB view raw
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, &current_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, &current_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}