use std::collections::{HashMap, VecDeque};
use std::io::{self, Read, Write};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use go_lib::context::{with_timeout, CancelFn, Context};
use go_lib::net::TcpStream;
use url::Url;
use crate::cookie::{Cookie, CookieJar};
use crate::error::HttpError;
use crate::header::Header;
use crate::parse::response::{read_response, ParsedResponse};
use crate::parse::transfer::Body;
use crate::request::Request;
use crate::response::Response;
pub trait RoundTripper: Send + Sync {
fn round_trip(&self, req: Request) -> Result<Response, HttpError>;
}
struct IdleConn {
stream: TcpStream,
}
pub struct Transport {
pub max_idle_conns_per_host: usize,
pub idle_conn_timeout: Option<Duration>,
pub dial_timeout: Option<Duration>,
pub tls_config: Option<Arc<rustls::ClientConfig>>,
pool: Mutex<HashMap<String, VecDeque<IdleConn>>>,
}
impl Transport {
pub fn new() -> Self {
Self {
max_idle_conns_per_host: 10,
idle_conn_timeout: Some(Duration::from_secs(90)),
dial_timeout: Some(Duration::from_secs(30)),
tls_config: None,
pool: Mutex::new(HashMap::new()),
}
}
fn acquire(&self, host_port: &str) -> io::Result<TcpStream> {
if let Some(conn) = self
.pool
.lock()
.unwrap()
.get_mut(host_port)
.and_then(|q| q.pop_front())
{
return Ok(conn.stream);
}
TcpStream::connect(host_port)
}
fn release(&self, host_port: &str, stream: TcpStream) {
let mut pool = self.pool.lock().unwrap();
let queue = pool.entry(host_port.to_owned()).or_default();
if queue.len() < self.max_idle_conns_per_host {
queue.push_back(IdleConn { stream });
}
}
}
impl Default for Transport {
fn default() -> Self {
Self::new()
}
}
impl RoundTripper for Transport {
fn round_trip(&self, mut req: Request) -> Result<Response, HttpError> {
let scheme = req.url.scheme();
let is_https = scheme == "https";
let host = req.url.host_str().unwrap_or("localhost");
let port = req.url.port_or_known_default()
.unwrap_or(if is_https { 443 } else { 80 });
let host_port = format!("{host}:{port}");
let stream = self.acquire(&host_port).map_err(HttpError::Io)?;
if is_https {
let tls_cfg = match &self.tls_config {
Some(c) => Arc::clone(c),
None => crate::tls::default_client_config(),
};
let server_name = rustls::pki_types::ServerName::try_from(host.to_owned())
.map_err(|e| HttpError::Tls(e.to_string()))?;
let client_conn = rustls::ClientConnection::new(tls_cfg, server_name)
.map_err(|e| HttpError::Tls(e.to_string()))?;
let mut tls = rustls::StreamOwned::new(client_conn, stream);
send_request(&mut tls, &mut req)?;
let read_ptr: *mut dyn Read = &mut tls as &mut dyn Read as *mut dyn Read;
let parsed = read_response(
RawRead(read_ptr),
Some(req.method.as_str()),
crate::parse::request::DEFAULT_MAX_HEADER_BYTES,
)?;
Ok(parsed_response_to_response(parsed))
} else {
let mut stream = stream;
send_request(&mut stream, &mut req)?;
let mut parsed = read_response(
stream.try_clone().map_err(HttpError::Io)?,
Some(req.method.as_str()),
crate::parse::request::DEFAULT_MAX_HEADER_BYTES,
)?;
let keep_alive = is_keep_alive_parsed(&parsed, req.proto_minor);
if keep_alive {
let bytes = parsed.body.read_to_vec().map_err(|_| HttpError::BodyRead)?;
parsed.body = Body::Unbounded(Box::new(io::Cursor::new(bytes)));
self.release(&host_port, stream);
}
Ok(parsed_response_to_response(parsed))
}
}
}
struct RawRead(*mut dyn Read);
unsafe impl Send for RawRead {}
impl Read for RawRead {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
unsafe { (*self.0).read(buf) }
}
}
pub struct Client {
pub transport: Arc<dyn RoundTripper>,
pub timeout: Option<Duration>,
pub max_redirects: usize,
pub jar: Option<Arc<dyn CookieJar>>,
}
impl Client {
pub fn new() -> Self {
Self {
transport: Arc::new(Transport::new()),
timeout: None,
max_redirects: 10,
jar: None,
}
}
pub fn get(&self, url: &str) -> Result<Response, HttpError> {
let req = Request::new("GET", url, None)?;
self.do_request(req)
}
pub fn post(
&self,
url: &str,
content_type: &str,
body: Body,
) -> Result<Response, HttpError> {
let mut req = Request::new("POST", url, Some(body))?;
req.header.set("Content-Type", content_type);
self.do_request(req)
}
pub fn post_form(
&self,
url: &str,
values: &[(&str, &str)],
) -> Result<Response, HttpError> {
let encoded = url_encode(values);
let body = Body::Unbounded(Box::new(io::Cursor::new(encoded.into_bytes())));
self.post(url, "application/x-www-form-urlencoded", body)
}
pub fn head(&self, url: &str) -> Result<Response, HttpError> {
let req = Request::new("HEAD", url, None)?;
self.do_request(req)
}
pub fn do_request(&self, req: Request) -> Result<Response, HttpError> {
let (_cancel, ctx_req) = apply_timeout(&req, self.timeout);
let mut req = if let Some(ctx) = ctx_req { req.with_context(ctx) } else { req };
if let Some(jar) = &self.jar {
attach_cookies(&mut req, jar.as_ref());
}
let mut redirects = 0usize;
loop {
let method = req.method.clone();
let url = req.url.clone();
let mut resp = self.transport.round_trip(req)?;
if let Some(jar) = &self.jar {
store_cookies(&url, &resp.header, jar.as_ref());
}
let status = resp.status;
if !is_redirect(status) {
return Ok(resp);
}
if redirects >= self.max_redirects {
return Err(HttpError::TooManyRedirects);
}
redirects += 1;
let location = resp
.header
.get("Location")
.ok_or_else(|| HttpError::InvalidUrl("redirect with no Location".into()))?
.to_owned();
let _ = resp.body_bytes();
let new_url = resolve_url(&url, &location)?;
let new_method = match status {
301 | 302 | 303 => {
if method == "POST" { "GET".to_owned() } else { method }
}
_ => method,
};
let body = if new_method == "GET" || new_method == "HEAD" {
None
} else {
None };
let mut new_req = Request::new(&new_method, new_url.as_str(), body)?;
forward_headers(&mut new_req.header, &resp.header, same_origin(&url, &new_url));
if let Some(jar) = &self.jar {
attach_cookies(&mut new_req, jar.as_ref());
}
req = new_req;
}
}
}
impl Default for Client {
fn default() -> Self {
Self::new()
}
}
fn default_client() -> &'static Client {
use std::sync::OnceLock;
static DEFAULT: OnceLock<Client> = OnceLock::new();
DEFAULT.get_or_init(Client::new)
}
pub fn get(url: &str) -> Result<Response, HttpError> {
default_client().get(url)
}
pub fn post(url: &str, content_type: &str, body: Body) -> Result<Response, HttpError> {
default_client().post(url, content_type, body)
}
pub fn post_form(url: &str, values: &[(&str, &str)]) -> Result<Response, HttpError> {
default_client().post_form(url, values)
}
pub fn head(url: &str) -> Result<Response, HttpError> {
default_client().head(url)
}
fn send_request(w: &mut impl Write, req: &mut Request) -> Result<(), HttpError> {
req.write_header_to(w)?;
if let Some(body) = req.body.take() {
use std::io::Read;
let mut body = body;
let mut buf = [0u8; 8192];
loop {
let n = body.read(&mut buf).map_err(|_| HttpError::BodyRead)?;
if n == 0 { break; }
w.write_all(&buf[..n])?;
}
}
Ok(())
}
fn is_keep_alive_parsed(resp: &ParsedResponse, req_minor: u8) -> bool {
let conn = resp.header.get("Connection").unwrap_or("").to_ascii_lowercase();
if conn.contains("close") { return false; }
if req_minor == 0 { conn.contains("keep-alive") } else { true }
}
fn parsed_response_to_response(p: ParsedResponse) -> Response {
Response {
status: p.status,
status_text: p.status_text,
proto: p.proto,
proto_major: p.proto_major,
proto_minor: p.proto_minor,
header: p.header,
body: match p.body {
Body::Empty => None,
other => Some(other),
},
content_length: p.content_length,
transfer_encoding: p.transfer_encoding,
trailer: Header::new(),
}
}
fn forward_headers(dst: &mut Header, src: &Header, same_origin: bool) {
for (name, values) in src.iter() {
let lower = name.to_ascii_lowercase();
if matches!(
lower.as_str(),
"connection" | "keep-alive" | "proxy-authenticate"
| "proxy-authorization" | "te" | "trailers"
| "transfer-encoding" | "upgrade"
) {
continue;
}
if !same_origin && lower == "authorization" {
continue;
}
for v in values {
dst.add(name, v.as_str());
}
}
}
fn is_redirect(status: u16) -> bool {
matches!(status, 301 | 302 | 303 | 307 | 308)
}
fn resolve_url(base: &Url, location: &str) -> Result<Url, HttpError> {
if location.starts_with("http://") || location.starts_with("https://") {
Url::parse(location).map_err(|e| HttpError::InvalidUrl(e.to_string()))
} else {
base.join(location).map_err(|e| HttpError::InvalidUrl(e.to_string()))
}
}
fn same_origin(a: &Url, b: &Url) -> bool {
a.scheme() == b.scheme()
&& a.host_str() == b.host_str()
&& a.port() == b.port()
}
fn attach_cookies(req: &mut Request, jar: &dyn CookieJar) {
let cookies = jar.cookies(&req.url);
if !cookies.is_empty() {
let pairs: Vec<String> = cookies
.iter()
.map(|c| format!("{}={}", c.name, c.value))
.collect();
req.header.set("Cookie", pairs.join("; "));
}
}
fn store_cookies(url: &Url, header: &Header, jar: &dyn CookieJar) {
let cookies: Vec<Cookie> = header
.values("Set-Cookie")
.iter()
.filter_map(|v| {
let eq = v.find('=')?;
let name = v[..eq].trim().to_owned();
let rest = &v[eq + 1..];
let value = rest.split(';').next().unwrap_or("").trim().to_owned();
Some(Cookie::new(name, value))
})
.collect();
if !cookies.is_empty() {
jar.set_cookies(url, &cookies);
}
}
fn apply_timeout(req: &Request, timeout: Option<Duration>) -> (Option<CancelFn>, Option<Context>) {
match timeout {
None => (None, None),
Some(d) => {
let (ctx, cancel) = with_timeout(req.context(), d);
(Some(cancel), Some(ctx))
}
}
}
fn url_encode(values: &[(&str, &str)]) -> String {
values
.iter()
.map(|(k, v)| format!("{}={}", encode_form(k), encode_form(v)))
.collect::<Vec<_>>()
.join("&")
}
fn encode_form(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
| b'-' | b'_' | b'.' | b'~' => out.push(b as char),
b' ' => out.push('+'),
_ => {
out.push('%');
out.push_str(&format!("{b:02X}"));
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_encode_basic() {
let pairs = [("q", "hello world"), ("lang", "rust")];
assert_eq!(url_encode(&pairs), "q=hello+world&lang=rust");
}
#[test]
fn url_encode_special_chars() {
let pairs = [("a", "b&c=d")];
assert_eq!(url_encode(&pairs), "a=b%26c%3Dd");
}
#[test]
fn resolve_url_absolute() {
let base = Url::parse("http://example.com/foo").unwrap();
let resolved = resolve_url(&base, "http://other.com/bar").unwrap();
assert_eq!(resolved.as_str(), "http://other.com/bar");
}
#[test]
fn resolve_url_relative() {
let base = Url::parse("http://example.com/a/b").unwrap();
let resolved = resolve_url(&base, "/c").unwrap();
assert_eq!(resolved.as_str(), "http://example.com/c");
}
#[test]
fn is_redirect_codes() {
for code in [301u16, 302, 303, 307, 308] {
assert!(is_redirect(code), "{code} should be redirect");
}
for code in [200u16, 404, 500] {
assert!(!is_redirect(code), "{code} should not be redirect");
}
}
#[test]
fn same_origin_check() {
let a = Url::parse("http://example.com/foo").unwrap();
let b = Url::parse("http://example.com/bar").unwrap();
let c = Url::parse("https://example.com/foo").unwrap();
let d = Url::parse("http://other.com/foo").unwrap();
assert!(same_origin(&a, &b));
assert!(!same_origin(&a, &c)); assert!(!same_origin(&a, &d)); }
#[test]
fn transport_pool_reuse() {
let t = Transport::new();
assert_eq!(t.max_idle_conns_per_host, 10);
assert!(t.pool.lock().unwrap().is_empty());
}
}