use std::{
sync::{Arc, LazyLock, RwLock},
time::Duration,
};
use ureq::{
config::IpFamily,
http::{self, HeaderMap, Uri},
typestate::{WithBody, WithoutBody},
Agent, Proxy, RequestBuilder,
};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone, Debug)]
pub struct ClientConfig {
pub user_agent: Option<String>,
pub headers: Option<HeaderMap>,
pub proxy: Option<Proxy>,
pub timeout: Option<Duration>,
pub ip_family: IpFamily,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
user_agent: Some("pkgforge/soar".into()),
proxy: None,
headers: None,
timeout: None,
ip_family: IpFamily::Any,
}
}
}
impl ClientConfig {
pub fn build(&self) -> Agent {
let mut config = ureq::Agent::config_builder()
.timeout_global(self.timeout)
.timeout_connect(Some(CONNECT_TIMEOUT))
.ip_family(self.ip_family);
if self.proxy.is_some() {
config = config.proxy(self.proxy.clone());
}
if let Some(user_agent) = &self.user_agent {
config = config.user_agent(user_agent);
}
config.build().into()
}
}
struct SharedClient {
agent: Agent,
config: ClientConfig,
}
static SHARED_CLIENT_STATE: LazyLock<Arc<RwLock<SharedClient>>> = LazyLock::new(|| {
let config = ClientConfig::default();
let agent = config.build();
Arc::new(RwLock::new(SharedClient {
agent,
config,
}))
});
#[derive(Clone, Default)]
pub struct SharedAgent;
impl SharedAgent {
pub fn new() -> Self {
Self
}
pub fn head<T>(&self, uri: T) -> RequestBuilder<WithoutBody>
where
Uri: TryFrom<T>,
<Uri as TryFrom<T>>::Error: Into<http::Error>,
{
let state = SHARED_CLIENT_STATE.read().unwrap();
let req = state.agent.head(uri);
apply_headers(req, &state.config.headers)
}
pub fn get<T>(&self, uri: T) -> RequestBuilder<WithoutBody>
where
Uri: TryFrom<T>,
<Uri as TryFrom<T>>::Error: Into<http::Error>,
{
let state = SHARED_CLIENT_STATE.read().unwrap();
let req = state.agent.get(uri);
apply_headers(req, &state.config.headers)
}
pub fn post<T>(&self, uri: T) -> RequestBuilder<WithBody>
where
Uri: TryFrom<T>,
<Uri as TryFrom<T>>::Error: Into<http::Error>,
{
let state = SHARED_CLIENT_STATE.read().unwrap();
let req = state.agent.post(uri);
apply_headers(req, &state.config.headers)
}
pub fn put<T>(&self, uri: T) -> RequestBuilder<WithBody>
where
Uri: TryFrom<T>,
<Uri as TryFrom<T>>::Error: Into<http::Error>,
{
let state = SHARED_CLIENT_STATE.read().unwrap();
let req = state.agent.put(uri);
apply_headers(req, &state.config.headers)
}
pub fn delete<T>(&self, uri: T) -> RequestBuilder<WithoutBody>
where
Uri: TryFrom<T>,
<Uri as TryFrom<T>>::Error: Into<http::Error>,
{
let state = SHARED_CLIENT_STATE.read().unwrap();
let req = state.agent.delete(uri);
apply_headers(req, &state.config.headers)
}
}
fn apply_headers<B>(mut req: RequestBuilder<B>, headers: &Option<HeaderMap>) -> RequestBuilder<B> {
if let Some(headers) = headers {
for (key, value) in headers.iter() {
req = req.header(key, value);
}
}
req
}
pub static SHARED_AGENT: LazyLock<SharedAgent> = LazyLock::new(SharedAgent::new);
pub fn configure_http_client<F>(updater: F)
where
F: FnOnce(&mut ClientConfig),
{
let mut state = SHARED_CLIENT_STATE.write().unwrap();
let mut new_config = state.config.clone();
updater(&mut new_config);
let new_agent = new_config.build();
state.agent = new_agent;
state.config = new_config;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_config_default() {
let config = ClientConfig::default();
assert_eq!(config.user_agent, Some("pkgforge/soar".to_string()));
assert!(config.proxy.is_none());
assert!(config.headers.is_none());
assert!(config.timeout.is_none());
assert_eq!(config.ip_family, IpFamily::Any);
}
#[test]
fn test_client_config_build() {
let config = ClientConfig::default();
let agent = config.build();
let _ = agent;
}
#[test]
fn test_client_config_with_timeout() {
let config = ClientConfig {
user_agent: Some("test-agent".to_string()),
proxy: None,
headers: None,
timeout: Some(Duration::from_secs(30)),
ip_family: IpFamily::Any,
};
let agent = config.build();
let _ = agent;
}
#[test]
fn test_client_config_sets_connect_timeout() {
let agent = ClientConfig::default().build();
assert_eq!(agent.config().timeouts().connect, Some(CONNECT_TIMEOUT));
}
#[test]
fn test_client_config_without_proxy_falls_back_to_env() {
let agent = ClientConfig::default().build();
assert_eq!(
agent.config().proxy().is_some(),
Proxy::try_from_env().is_some()
);
}
#[test]
fn test_client_config_explicit_proxy_overrides_env() {
let config = ClientConfig {
proxy: Some(Proxy::new("http://127.0.0.1:8080").unwrap()),
..Default::default()
};
let agent = config.build();
assert_eq!(agent.config().proxy().unwrap().port(), 8080);
}
#[test]
fn test_client_config_ip_family() {
for family in [IpFamily::Any, IpFamily::Ipv4Only, IpFamily::Ipv6Only] {
let config = ClientConfig {
ip_family: family,
..Default::default()
};
let agent = config.build();
assert_eq!(agent.config().ip_family(), family);
}
}
#[test]
fn test_shared_agent_new() {
let agent = SharedAgent::new();
let _ = agent;
}
#[test]
fn test_shared_agent_get() {
let agent = SharedAgent::new();
let req = agent.get("https://example.com");
let _ = req;
}
#[test]
fn test_shared_agent_post() {
let agent = SharedAgent::new();
let req = agent.post("https://example.com");
let _ = req;
}
#[test]
fn test_shared_agent_put() {
let agent = SharedAgent::new();
let req = agent.put("https://example.com");
let _ = req;
}
#[test]
fn test_shared_agent_delete() {
let agent = SharedAgent::new();
let req = agent.delete("https://example.com");
let _ = req;
}
#[test]
fn test_shared_agent_head() {
let agent = SharedAgent::new();
let req = agent.head("https://example.com");
let _ = req;
}
#[test]
fn test_configure_http_client() {
configure_http_client(|cfg| {
cfg.user_agent = Some("custom-agent/1.0".to_string());
});
let agent = SharedAgent::new();
let _ = agent.get("https://example.com");
}
#[test]
fn test_configure_http_client_timeout() {
configure_http_client(|cfg| {
cfg.timeout = Some(Duration::from_secs(10));
});
let agent = SharedAgent::new();
let _ = agent.get("https://example.com");
}
#[test]
fn test_shared_agent_clone() {
let agent1 = SharedAgent::new();
let agent2 = agent1.clone();
let _ = agent1.get("https://example.com");
let _ = agent2.get("https://example.com");
}
#[test]
fn test_shared_agent_default() {
let agent = SharedAgent;
let _ = agent.get("https://example.com");
}
#[test]
fn test_apply_headers_none() {
let agent: ureq::Agent = ureq::Agent::config_builder().build().into();
let req = agent.get("https://example.com");
let req = apply_headers(req, &None);
let _ = req;
}
#[test]
fn test_apply_headers_some() {
let agent: ureq::Agent = ureq::Agent::config_builder().build().into();
let req = agent.get("https://example.com");
let mut headers = ureq::http::HeaderMap::new();
headers.insert(
ureq::http::header::USER_AGENT,
ureq::http::HeaderValue::from_static("test-agent"),
);
let req = apply_headers(req, &Some(headers));
let _ = req;
}
#[test]
fn test_client_config_clone() {
let config1 = ClientConfig::default();
let config2 = config1.clone();
assert_eq!(config1.user_agent, config2.user_agent);
}
#[test]
fn test_client_config_debug() {
let config = ClientConfig::default();
let debug = format!("{:?}", config);
assert!(debug.contains("ClientConfig"));
}
}