use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;
use tokio_rustls::{TlsConnector, client::TlsStream};
use rustls::{ClientConfig, RootCertStore};
use rustls::pki_types::ServerName;
use bytes::BytesMut;
use h2::client;
use http::method::Method;
use http::uri::{Scheme, Uri};
use http;
use crate::error::{ReqresError, Result};
use crate::request::Request;
use crate::response::Response;
use crate::pool::{ConnectionPool, PoolKey, PoolConfig, PooledConnection, StreamWrapper};
use crate::cookie::CookieJar;
use crate::compression::Decompressor;
use crate::proxy::Proxy;
#[derive(Debug, Clone)]
pub struct ClientConfigInternal {
pub timeout: Duration,
pub follow_redirects: bool,
pub max_redirects: u32,
pub user_agent: String,
pub http2_enabled: bool,
pub pooling_enabled: bool,
pub compression_enabled: bool,
pub proxy: Option<Proxy>,
pub cookie_jar: Arc<CookieJar>,
cookie_jar_mut: Arc<std::sync::Mutex<CookieJar>>,
}
impl Default for ClientConfigInternal {
fn default() -> Self {
let jar = Arc::new(CookieJar::new());
ClientConfigInternal {
timeout: Duration::from_secs(30),
follow_redirects: true,
max_redirects: 10,
user_agent: "reqres/0.6.0".to_string(),
http2_enabled: true,
pooling_enabled: true,
compression_enabled: true,
proxy: None,
cookie_jar: jar.clone(),
cookie_jar_mut: Arc::new(std::sync::Mutex::new((*jar).clone())),
}
}
}
#[derive(Debug)]
pub struct ClientBuilder {
config: ClientConfigInternal,
}
impl ClientBuilder {
pub fn new() -> Self {
ClientBuilder {
config: ClientConfigInternal::default(),
}
}
pub fn timeout(mut self, duration: Duration) -> Self {
self.config.timeout = duration;
self
}
pub fn follow_redirects(mut self, follow: bool) -> Self {
self.config.follow_redirects = follow;
self
}
pub fn max_redirects(mut self, max: u32) -> Self {
self.config.max_redirects = max;
self
}
pub fn user_agent(mut self, agent: impl Into<String>) -> Self {
self.config.user_agent = agent.into();
self
}
pub fn http2_prior_knowledge(mut self) -> Self {
self.config.http2_enabled = true;
self
}
pub fn http1_only(mut self) -> Self {
self.config.http2_enabled = false;
self
}
pub fn enable_pooling(mut self) -> Self {
self.config.pooling_enabled = true;
self
}
pub fn disable_pooling(mut self) -> Self {
self.config.pooling_enabled = false;
self
}
pub fn enable_compression(mut self) -> Self {
self.config.compression_enabled = true;
self
}
pub fn disable_compression(mut self) -> Self {
self.config.compression_enabled = false;
self
}
pub fn proxy(mut self, proxy: Proxy) -> Self {
self.config.proxy = Some(proxy);
self
}
pub fn cookie_jar(mut self, jar: CookieJar) -> Self {
self.config.cookie_jar = Arc::new(jar.clone());
self.config.cookie_jar_mut = Arc::new(std::sync::Mutex::new(jar));
self
}
pub fn build(self) -> Result<Client> {
let root_store = RootCertStore::from_iter(
webpki_roots::TLS_SERVER_ROOTS.iter().cloned()
);
let mut config_builder = ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
if self.config.http2_enabled {
config_builder.alpn_protocols = vec!["h2".into(), "http/1.1".into()];
} else {
config_builder.alpn_protocols = vec!["http/1.1".into()];
}
let tls_config = config_builder;
let tls_connector = TlsConnector::from(Arc::new(tls_config));
let pool = if self.config.pooling_enabled {
Some(ConnectionPool::new(PoolConfig::default()))
} else {
None
};
let cookie_jar_mut = self.config.cookie_jar_mut.clone();
Ok(Client {
config: self.config,
tls_connector,
pool,
cookie_jar_mut,
})
}
}
impl Default for ClientBuilder {
fn default() -> Self {
Self::new()
}
}
pub struct Client {
config: ClientConfigInternal,
tls_connector: TlsConnector,
pool: Option<ConnectionPool>,
#[allow(dead_code)]
cookie_jar_mut: Arc<std::sync::Mutex<CookieJar>>, }
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl Client {
pub fn new() -> Result<Self> {
ClientBuilder::new().build()
}
pub fn builder() -> ClientBuilder {
ClientBuilder::new()
}
pub async fn request(&self, request: Request) -> Result<Response> {
self.request_with_redirects(request, 0).await
}
async fn request_with_redirects(&self, request: Request, redirect_count: u32) -> Result<Response> {
let original_url = request.url.clone();
let response = self.execute_request(request).await?;
if self.config.follow_redirects && response.is_redirect() {
if redirect_count >= self.config.max_redirects {
return Err(ReqresError::TooManyRedirects);
}
if let Some(location) = response.location() {
let new_url = if location.starts_with("http://") || location.starts_with("https://") {
location.clone()
} else if location.starts_with('/') {
let (is_https, host, port, _) = parse_url(&original_url)?;
let scheme = if is_https { "https" } else { "http" };
if (is_https && port == 443) || (!is_https && port == 80) {
format!("{}://{}{}", scheme, host, location)
} else {
format!("{}://{}:{}{}", scheme, host, port, location)
}
} else {
return Err(ReqresError::InvalidResponse(format!("Unsupported relative redirect: {}", location)));
};
let new_request = crate::Request::get(new_url).build()?;
return Box::pin(self.request_with_redirects(new_request, redirect_count + 1)).await;
}
}
Ok(response)
}
async fn execute_request(&self, request: Request) -> Result<Response> {
let (is_https, host, port, path) = parse_url(&request.url)?;
let pool_key = PoolKey::new(host.clone(), port);
if is_https && self.config.http2_enabled {
eprintln!("Creating new HTTP/2 connection to {}:{}", host, port);
let stream = self.create_new_connection(&host, port, is_https).await?;
if let StreamWrapper::Tls(tls_stream) = &stream {
let negotiated_protocol = tls_stream.get_ref().1.alpn_protocol();
let is_h2 = negotiated_protocol.as_deref() == Some(b"h2");
if is_h2 {
eprintln!("Using HTTP/2 for {}:{}", host, path);
let tls_stream_owned = if let StreamWrapper::Tls(tls) = stream {
tls
} else {
unreachable!("Expected TlsStream")
};
return self.execute_h2_request(tls_stream_owned, request, host.clone(), path).await;
}
}
return self.execute_h1_request(stream, request, &host).await;
}
let stream = if let Some(ref pool) = self.pool {
match pool.acquire(pool_key.clone()).await {
Some(pooled_conn) => {
eprintln!("Reusing pooled connection to {}:{}", host, port);
pooled_conn.stream
}
None => {
eprintln!("Creating new connection to {}:{}", host, port);
self.create_new_connection(&host, port, is_https).await?
}
}
} else {
eprintln!("Creating new connection to {}:{}", host, port);
self.create_new_connection(&host, port, is_https).await?
};
let response = self.execute_h1_request(stream, request, &host).await?;
if let Some(ref pool) = self.pool {
let mut pooled_conn = PooledConnection::new(self.create_new_connection(&host, port, is_https).await?);
pooled_conn.is_active = false;
pool.release(pool_key, pooled_conn).await;
}
Ok(response)
}
async fn create_new_connection(&self, host: &str, port: u16, is_https: bool) -> Result<StreamWrapper> {
let addr = format!("{}:{}", host, port);
let tcp_stream = timeout(
self.config.timeout,
TcpStream::connect(&addr)
).await.map_err(|_| ReqresError::Timeout)??;
if is_https {
let server_name = ServerName::try_from(host.to_string())
.map_err(|e| ReqresError::Tls(format!("Invalid server name: {}", e)))?;
let tls_stream = timeout(
self.config.timeout,
self.tls_connector.connect(server_name, tcp_stream)
).await.map_err(|_| ReqresError::Timeout)??;
Ok(StreamWrapper::Tls(tls_stream))
} else {
Ok(StreamWrapper::Plain(tcp_stream))
}
}
async fn execute_h2_request(
&self,
tls_stream: TlsStream<TcpStream>,
request: Request,
host: String,
path: String,
) -> Result<Response> {
let (mut h2_client, conn) = client::handshake(tls_stream).await
.map_err(|e| ReqresError::Connection(format!("HTTP/2 handshake failed: {}", e)))?;
let _conn_task = tokio::spawn(async move {
if let Err(e) = conn.await {
eprintln!("HTTP/2 connection error: {:?}", e);
}
});
let uri = Uri::builder()
.scheme(Scheme::HTTPS)
.authority(host.clone())
.path_and_query(&path)
.build()
.map_err(|e| ReqresError::InvalidUrl(format!("Invalid URI: {}", e)))?;
let method = match request.method {
crate::request::Method::GET => Method::GET,
crate::request::Method::POST => Method::POST,
crate::request::Method::PUT => Method::PUT,
crate::request::Method::DELETE => Method::DELETE,
crate::request::Method::HEAD => Method::HEAD,
crate::request::Method::OPTIONS => Method::OPTIONS,
crate::request::Method::PATCH => Method::PATCH,
};
let mut h2_req = http::Request::builder()
.method(method)
.uri(uri)
.version(http::Version::HTTP_2);
for (key, value) in &request.headers {
let key_lower = key.to_lowercase();
if key_lower != "host" && key_lower != "cookie" && key_lower != "accept-encoding" {
h2_req = h2_req.header(key, value);
}
}
if let Ok(jar) = self.cookie_jar_mut.lock() {
if !jar.is_empty() {
h2_req = h2_req.header("Cookie", jar.build_cookie_header());
}
}
if self.config.compression_enabled {
h2_req = h2_req.header("Accept-Encoding", "gzip, deflate, br");
}
let h2_req = h2_req.body(())?;
let (response_future, mut send_stream) = h2_client.send_request(h2_req, false)
.map_err(|e| ReqresError::Connection(format!("HTTP/2 send request failed: {}", e)))?;
if !request.body.is_empty() {
let body_data = request.body.as_bytes();
send_stream.send_data(body_data.into(), true)
.map_err(|e| ReqresError::Connection(format!("HTTP/2 send data failed: {}", e)))?;
} else {
send_stream.send_data(bytes::Bytes::new(), true)
.map_err(|e| ReqresError::Connection(format!("HTTP/2 close stream failed: {}", e)))?;
}
let http2_response = response_future.await
.map_err(|e| ReqresError::Connection(format!("HTTP/2 response failed: {}", e)))?;
let headers = http2_response.headers().clone();
let status = http2_response.status().as_u16();
let status_text = http2_response.status().canonical_reason().unwrap_or("Unknown").to_string();
let mut response_body = http2_response.into_body();
let mut buffer = BytesMut::with_capacity(8192);
loop {
match response_body.data().await {
Some(Ok(chunk)) => {
buffer.extend_from_slice(&chunk);
let _ = response_body.flow_control().release_capacity(chunk.len());
}
Some(Err(e)) => {
return Err(ReqresError::Connection(format!("HTTP/2 read body failed: {}", e)));
}
None => break,
}
}
let mut response_headers = std::collections::HashMap::new();
for (key, value) in headers.iter() {
if let Ok(value_str) = value.to_str() {
response_headers.insert(key.as_str().to_string(), value_str.to_string());
}
}
if let Ok(mut jar) = self.cookie_jar_mut.lock() {
jar.parse_from_headers(&response_headers);
}
let mut body = buffer.freeze();
if self.config.compression_enabled {
if let Some(content_encoding) = response_headers.get("content-encoding") {
if let Ok(decompressed) = Decompressor::auto_decompress(
body.clone(),
Some(content_encoding)
) {
body = decompressed;
response_headers.remove("content-encoding");
}
}
}
Ok(Response {
version: "HTTP/2.0".to_string(),
status,
status_text,
headers: response_headers,
body,
})
}
async fn execute_h1_request(
&self,
stream: StreamWrapper,
request: Request,
host: &str,
) -> Result<Response> {
let cookie_jar = self.cookie_jar_mut.lock().unwrap();
let http_request = build_http_request(
&request,
&host,
&cookie_jar,
self.config.compression_enabled,
);
drop(cookie_jar);
let buffer = match stream {
#[cfg(test)]
StreamWrapper::Dummy => unreachable!("Dummy stream should not be used in production"),
StreamWrapper::Plain(mut s) => {
timeout(
self.config.timeout,
s.write_all(&http_request)
).await.map_err(|_| ReqresError::Timeout)??;
let mut buffer = BytesMut::with_capacity(8192);
let mut temp_buf = vec![0u8; 4096];
loop {
match timeout(
self.config.timeout,
s.read(&mut temp_buf)
).await {
Ok(Ok(0)) => break,
Ok(Ok(n)) => buffer.extend_from_slice(&temp_buf[..n]),
Ok(Err(e)) => return Err(e.into()),
Err(_) => return Err(ReqresError::Timeout),
}
}
buffer
}
StreamWrapper::Tls(mut s) => {
timeout(
self.config.timeout,
s.write_all(&http_request)
).await.map_err(|_| ReqresError::Timeout)??;
let mut buffer = BytesMut::with_capacity(8192);
let mut temp_buf = vec![0u8; 4096];
loop {
match timeout(
self.config.timeout,
s.read(&mut temp_buf)
).await {
Ok(Ok(0)) => break,
Ok(Ok(n)) => buffer.extend_from_slice(&temp_buf[..n]),
Ok(Err(e)) => {
if is_tls_close_notify_error(&e) {
break;
}
return Err(e.into());
}
Err(_) => return Err(ReqresError::Timeout),
}
}
buffer
}
};
let response_str = String::from_utf8_lossy(&buffer).to_string();
let buffer_bytes = buffer.freeze();
let mut response = parse_http_response(&response_str, buffer_bytes)?;
if let Ok(mut jar) = self.cookie_jar_mut.lock() {
jar.parse_from_headers(&response.headers);
}
if self.config.compression_enabled {
if let Some(content_encoding) = response.headers.get("content-encoding") {
if let Ok(decompressed) = Decompressor::auto_decompress(
response.body.clone(),
Some(content_encoding)
) {
response.body = decompressed;
response.headers.remove("content-encoding");
if let Some(_) = response.headers.remove("content-length") {
response.headers.insert(
"content-length".to_string(),
response.body.len().to_string(),
);
}
}
}
}
Ok(response)
}
pub async fn get(&self, url: impl Into<String>) -> Result<Response> {
let request = Request::get(url).build()?;
self.request(request).await
}
pub async fn post(&self, url: impl Into<String>, body: impl Into<String>) -> Result<Response> {
let request = Request::post(url)
.content_type("text/plain")
.body(body)
.build()?;
self.request(request).await
}
pub async fn post_json(&self, url: impl Into<String>, json: impl serde::Serialize) -> Result<Response> {
let request = Request::post(url)
.json(json)?
.build()?;
self.request(request).await
}
pub async fn put(&self, url: impl Into<String>, body: impl Into<String>) -> Result<Response> {
let request = Request::put(url)
.content_type("text/plain")
.body(body)
.build()?;
self.request(request).await
}
pub async fn delete(&self, url: impl Into<String>) -> Result<Response> {
let request = Request::delete(url).build()?;
self.request(request).await
}
pub async fn pool_stats(&self) -> Option<crate::pool::PoolStats> {
if let Some(ref pool) = self.pool {
Some(pool.stats().await)
} else {
None
}
}
pub async fn pool_hit_rate(&self) -> Option<f64> {
if let Some(ref pool) = self.pool {
Some(pool.hit_rate().await)
} else {
None
}
}
pub async fn cleanup_pool(&self) {
if let Some(ref pool) = self.pool {
pool.cleanup_all().await;
}
}
}
impl Default for Client {
fn default() -> Self {
Self::new().expect("Failed to create default client")
}
}
fn is_tls_close_notify_error(error: &std::io::Error) -> bool {
let error_msg = error.to_string().to_lowercase();
error_msg.contains("close_notify") ||
error_msg.contains("unexpected eof") ||
error_msg.contains("peer closed")
}
fn parse_url(url: &str) -> Result<(bool, String, u16, String)> {
let (is_https, rest) = if url.starts_with("https://") {
(true, &url[8..])
} else if url.starts_with("http://") {
(false, &url[7..])
} else {
return Err(ReqresError::InvalidUrl(
"URL must start with http:// or https://".to_string(),
));
};
let (host_port, path) = if let Some(slash_pos) = rest.find('/') {
let (hp, p) = rest.split_at(slash_pos);
(hp, p.to_string())
} else {
(rest, "/".to_string())
};
let (host, port) = if let Some(colon_pos) = host_port.find(':') {
let (h, p) = host_port.split_at(colon_pos);
let port_num: u16 = p[1..].parse().map_err(|_| {
ReqresError::InvalidUrl(format!("Invalid port in URL: {}", url))
})?;
(h.to_string(), port_num)
} else {
let default_port = if is_https { 443 } else { 80 };
(host_port.to_string(), default_port)
};
Ok((is_https, host, port, path))
}
fn build_http_request(
request: &Request,
host: &str,
cookie_jar: &CookieJar,
compression_enabled: bool,
) -> Vec<u8> {
let (_, _, _, path) = parse_url(&request.url).unwrap_or((false, host.to_string(), 80, "/".to_string()));
let mut http_request = format!(
"{} {} HTTP/1.1\r\n",
request.method.as_str(),
path
);
http_request.push_str(&format!("Host: {}\r\n", host));
if !cookie_jar.is_empty() {
http_request.push_str(&format!("Cookie: {}\r\n", cookie_jar.build_cookie_header()));
}
if compression_enabled {
http_request.push_str("Accept-Encoding: gzip, deflate, br\r\n");
}
for (key, value) in &request.headers {
let key_lower = key.to_lowercase();
if key_lower != "host" && key_lower != "connection" &&
key_lower != "cookie" && key_lower != "accept-encoding" {
http_request.push_str(&format!("{}: {}\r\n", key, value));
}
}
http_request.push_str("Connection: close\r\n");
http_request.push_str("\r\n");
let mut result = http_request.into_bytes();
result.extend_from_slice(&request.body.as_bytes());
result
}
fn parse_http_response(response_str: &str, full_buffer: bytes::Bytes) -> Result<Response> {
let mut lines = response_str.lines();
let status_line = lines.next().ok_or("Empty response")?;
let parts: Vec<&str> = status_line.split_whitespace().collect();
if parts.len() < 3 {
return Err(ReqresError::InvalidResponse(
"Invalid status line".to_string(),
));
}
let version = parts[0].to_string();
let status: u16 = parts[1].parse().map_err(|_| {
ReqresError::InvalidResponse(format!("Invalid status code: {}", parts[1]))
})?;
let status_text = parts[2..].join(" ");
let mut headers = std::collections::HashMap::new();
let mut header_end_pos = 0;
let mut in_headers = true;
for (idx, line) in response_str.lines().enumerate() {
if in_headers {
if line.is_empty() {
let lines_before: String = response_str.lines().take(idx + 1).collect::<Vec<_>>().join("\n");
header_end_pos = lines_before.len() + 1; in_headers = false;
} else if idx > 0 { if let Some(colon_pos) = line.find(':') {
let (key, value) = line.split_at(colon_pos);
let value = value[1..].trim();
headers.insert(key.trim().to_string(), value.to_string());
}
}
}
}
let body = if header_end_pos > 0 && header_end_pos < full_buffer.len() {
full_buffer.slice(header_end_pos..)
} else {
bytes::Bytes::new()
};
Ok(Response {
version,
status,
status_text,
headers,
body,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn test_config_default() {
let config = ClientConfigInternal::default();
assert_eq!(config.timeout, Duration::from_secs(30));
assert_eq!(config.follow_redirects, true);
assert_eq!(config.max_redirects, 10);
assert_eq!(config.user_agent, "reqres/0.6.0");
assert_eq!(config.http2_enabled, true);
assert_eq!(config.pooling_enabled, true);
assert_eq!(config.compression_enabled, true);
assert!(config.proxy.is_none());
}
#[test]
fn test_builder_new() {
let builder = ClientBuilder::new();
assert_eq!(builder.config.timeout, Duration::from_secs(30));
assert_eq!(builder.config.follow_redirects, true);
}
#[test]
fn test_builder_default() {
let builder = ClientBuilder::default();
assert_eq!(builder.config.timeout, Duration::from_secs(30));
}
#[test]
fn test_builder_timeout() {
let builder = ClientBuilder::new().timeout(Duration::from_secs(60));
assert_eq!(builder.config.timeout, Duration::from_secs(60));
}
#[test]
fn test_builder_follow_redirects() {
let builder = ClientBuilder::new().follow_redirects(false);
assert_eq!(builder.config.follow_redirects, false);
}
#[test]
fn test_builder_max_redirects() {
let builder = ClientBuilder::new().max_redirects(5);
assert_eq!(builder.config.max_redirects, 5);
}
#[test]
fn test_builder_user_agent() {
let builder = ClientBuilder::new().user_agent("MyApp/1.0");
assert_eq!(builder.config.user_agent, "MyApp/1.0");
}
#[test]
fn test_builder_http2_prior_knowledge() {
let builder = ClientBuilder::new().http2_prior_knowledge();
assert_eq!(builder.config.http2_enabled, true);
}
#[test]
fn test_builder_http1_only() {
let builder = ClientBuilder::new().http1_only();
assert_eq!(builder.config.http2_enabled, false);
}
#[test]
fn test_builder_enable_pooling() {
let builder = ClientBuilder::new().enable_pooling();
assert_eq!(builder.config.pooling_enabled, true);
}
#[test]
fn test_builder_disable_pooling() {
let builder = ClientBuilder::new().disable_pooling();
assert_eq!(builder.config.pooling_enabled, false);
}
#[test]
fn test_builder_enable_compression() {
let builder = ClientBuilder::new().enable_compression();
assert_eq!(builder.config.compression_enabled, true);
}
#[test]
fn test_builder_disable_compression() {
let builder = ClientBuilder::new().disable_compression();
assert_eq!(builder.config.compression_enabled, false);
}
#[test]
fn test_builder_cookie_jar() {
let jar = CookieJar::new();
let builder = ClientBuilder::new().cookie_jar(jar);
assert_eq!(Arc::strong_count(&builder.config.cookie_jar), 1);
}
#[test]
fn test_builder_chain() {
let builder = ClientBuilder::new()
.timeout(Duration::from_secs(60))
.follow_redirects(false)
.max_redirects(5)
.user_agent("Test/1.0")
.http2_prior_knowledge()
.enable_pooling()
.enable_compression();
assert_eq!(builder.config.timeout, Duration::from_secs(60));
assert_eq!(builder.config.follow_redirects, false);
assert_eq!(builder.config.max_redirects, 5);
assert_eq!(builder.config.user_agent, "Test/1.0");
assert_eq!(builder.config.http2_enabled, true);
assert_eq!(builder.config.pooling_enabled, true);
assert_eq!(builder.config.compression_enabled, true);
}
#[test]
fn test_builder_build() {
let builder = ClientBuilder::new()
.timeout(Duration::from_secs(60))
.http1_only()
.disable_pooling()
.disable_compression();
let client = builder.build();
assert!(client.is_ok());
let client = client.unwrap();
assert_eq!(client.config.timeout, Duration::from_secs(60));
assert_eq!(client.config.http2_enabled, false);
assert_eq!(client.config.pooling_enabled, false);
assert_eq!(client.config.compression_enabled, false);
}
#[test]
fn test_client_new() {
let client = Client::new();
assert!(client.is_ok());
let client = client.unwrap();
assert_eq!(client.config.timeout, Duration::from_secs(30));
assert_eq!(client.config.http2_enabled, true);
}
#[test]
fn test_client_builder() {
let builder = Client::builder();
let client = builder.timeout(Duration::from_secs(45)).build();
assert!(client.is_ok());
let client = client.unwrap();
assert_eq!(client.config.timeout, Duration::from_secs(45));
}
#[test]
fn test_parse_url_valid() {
let result = parse_url("https://example.com/path?query=value");
assert!(result.is_ok());
let (is_https, host, port, path) = result.unwrap();
assert_eq!(is_https, true);
assert_eq!(host, "example.com");
assert_eq!(port, 443);
assert_eq!(path, "/path?query=value");
}
#[test]
fn test_parse_url_http() {
let result = parse_url("http://example.com/test");
assert!(result.is_ok());
let (is_https, host, port, path) = result.unwrap();
assert_eq!(is_https, false);
assert_eq!(host, "example.com");
assert_eq!(port, 80);
assert_eq!(path, "/test");
}
#[test]
fn test_parse_url_with_port() {
let result = parse_url("https://example.com:8443/api");
assert!(result.is_ok());
let (is_https, host, port, path) = result.unwrap();
assert_eq!(is_https, true);
assert_eq!(host, "example.com");
assert_eq!(port, 8443);
assert_eq!(path, "/api");
}
#[test]
fn test_parse_url_invalid_scheme() {
let result = parse_url("ftp://example.com/test");
assert!(result.is_err());
}
#[test]
fn test_parse_url_invalid_format() {
let result = parse_url("not a url");
assert!(result.is_err());
}
}