Files
xevion.dev/src/http.rs
Xevion dd1ce186d2 refactor: consolidate HTTP client for TCP/Unix socket handling
Extract reqwest client creation into dedicated HttpClient abstraction that handles both TCP and Unix socket connections transparently. Simplifies proxy logic by removing duplicate URL construction and client selection throughout the codebase.
2026-01-07 14:34:32 -06:00

147 lines
4.4 KiB
Rust

use reqwest::Method;
use std::path::PathBuf;
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ClientError {
#[error("Failed to build reqwest client: {0}")]
BuildError(#[from] reqwest::Error),
#[error("Invalid downstream URL: {0}")]
InvalidUrl(String),
}
#[derive(Clone)]
pub struct HttpClient {
client: reqwest::Client,
target: TargetUrl,
}
#[derive(Debug, Clone)]
enum TargetUrl {
Tcp(String), // Base URL like "http://localhost:5173"
Unix(PathBuf), // Socket path like "/tmp/bun.sock"
}
impl HttpClient {
/// Create a new HttpClient from a downstream URL
///
/// Accepts:
/// - TCP: "http://localhost:5173", "https://example.com"
/// - Unix: "/tmp/bun.sock", "./relative.sock"
pub fn new(downstream: &str) -> Result<Self, ClientError> {
let target = if downstream.starts_with('/') || downstream.starts_with("./") {
TargetUrl::Unix(PathBuf::from(downstream))
} else if downstream.starts_with("http://") || downstream.starts_with("https://") {
TargetUrl::Tcp(downstream.to_string())
} else {
return Err(ClientError::InvalidUrl(downstream.to_string()));
};
tracing::debug!(
target = ?target,
downstream = %downstream,
"Creating HTTP client"
);
let client = match &target {
TargetUrl::Unix(path) => reqwest::Client::builder()
.pool_max_idle_per_host(8)
.pool_idle_timeout(Duration::from_secs(600))
.timeout(Duration::from_secs(5))
.connect_timeout(Duration::from_secs(3))
.redirect(reqwest::redirect::Policy::none())
.unix_socket(path.clone())
.build()?,
TargetUrl::Tcp(_) => reqwest::Client::builder()
.pool_max_idle_per_host(8)
.pool_idle_timeout(Duration::from_secs(600))
.tcp_keepalive(Some(Duration::from_secs(60)))
.timeout(Duration::from_secs(5))
.connect_timeout(Duration::from_secs(3))
.redirect(reqwest::redirect::Policy::none())
.build()?,
};
Ok(Self { client, target })
}
/// Build a full URL from a path
///
/// Examples:
/// - TCP target "http://localhost:5173" + "/api/health" → "http://localhost:5173/api/health"
/// - Unix target "/tmp/bun.sock" + "/api/health" → "http://localhost/api/health"
fn build_url(&self, path: &str) -> String {
match &self.target {
TargetUrl::Tcp(base) => format!("{}{}", base, path),
TargetUrl::Unix(_) => format!("http://localhost{}", path),
}
}
pub fn get(&self, path: &str) -> reqwest::RequestBuilder {
self.client.get(self.build_url(path))
}
pub fn post(&self, path: &str) -> reqwest::RequestBuilder {
self.client.post(self.build_url(path))
}
pub fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
self.client.request(method, self.build_url(path))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tcp_url_construction() {
let client = HttpClient::new("http://localhost:5173").unwrap();
assert_eq!(
client.build_url("/api/health"),
"http://localhost:5173/api/health"
);
assert_eq!(
client.build_url("/path?query=1"),
"http://localhost:5173/path?query=1"
);
}
#[test]
fn test_unix_url_construction() {
let client = HttpClient::new("/tmp/bun.sock").unwrap();
assert_eq!(
client.build_url("/api/health"),
"http://localhost/api/health"
);
assert_eq!(
client.build_url("/path?query=1"),
"http://localhost/path?query=1"
);
}
#[test]
fn test_relative_unix_socket() {
let client = HttpClient::new("./relative.sock").unwrap();
assert!(matches!(client.target, TargetUrl::Unix(_)));
}
#[test]
fn test_invalid_url() {
let result = HttpClient::new("not-a-valid-url");
assert!(result.is_err());
}
#[test]
fn test_https_url() {
let client = HttpClient::new("https://example.com").unwrap();
assert!(matches!(client.target, TargetUrl::Tcp(_)));
assert_eq!(
client.build_url("/api/test"),
"https://example.com/api/test"
);
}
}