use std::sync::Arc;
use std::time::Duration;
use reqwest::multipart;
use serde::de::DeserializeOwned;
use tokio::sync::RwLock;
use tokio::time::Instant;
use tracing::instrument;
use url::Url;
use crate::error::{ApiResponse, Error, RequestParams};
use crate::rate_limiter::RateLimiter;
use crate::retry::RetryPolicy;
use crate::types::auth::Credentials;
use crate::zone_client::ZoneClient;
const SENSITIVE_PARAMS: &[&str] = &["token", "pass", "totp"];
fn filter_sensitive_params(params: &[(&str, &str)]) -> RequestParams {
RequestParams(
params
.iter()
.filter(|(k, _)| !SENSITIVE_PARAMS.contains(k))
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
)
}
#[derive(Debug, Clone)]
pub struct Client {
pub(crate) base_url: Url,
pub(crate) http: reqwest::Client,
pub(crate) token: Arc<RwLock<Option<String>>>,
pub(crate) credentials: Option<Credentials>,
pub(crate) auto_reauth: bool,
pub(crate) retry_policy: Option<RetryPolicy>,
pub(crate) rate_limiter: Option<Arc<RateLimiter>>,
}
impl Client {
#[must_use]
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
pub async fn connect(
url: impl Into<String>,
username: &str,
password: &str,
) -> Result<Self, Error> {
let client = Self::builder()
.base_url(url)
.credentials(username, password)
.auto_reauth(true)
.build()?;
client.login(username, password).await?;
Ok(client)
}
#[must_use]
pub fn zone(&self, zone: impl Into<String>) -> ZoneClient<'_> {
ZoneClient::new(self, zone.into())
}
pub async fn resolve_zone(&self, domain: &str) -> Result<String, Error> {
let zones = self.list_zones().await?;
let mut candidate = domain;
loop {
if zones.iter().any(|z| z.name == candidate) {
return Ok(candidate.to_string());
}
match candidate.find('.') {
Some(idx) => candidate = &candidate[idx + 1..],
None => break,
}
}
Err(Error::Config {
reason: format!("no zone found for domain '{domain}'"),
})
}
pub async fn zone_for_domain(&self, domain: &str) -> Result<ZoneClient<'_>, Error> {
let zone_name = self.resolve_zone(domain).await?;
Ok(self.zone(zone_name))
}
async fn current_token(&self) -> Option<String> {
self.token.read().await.clone()
}
pub(crate) async fn do_send_form(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<reqwest::Response, Error> {
let token = self.current_token().await;
if token.is_none() {
let is_auth_path = path.contains("/user/login") || path.contains("/user/createToken");
if !is_auth_path {
return Err(Error::NotAuthenticated);
}
}
let url = self.base_url.join(path).map_err(|e| Error::Config {
reason: format!("invalid API path '{path}': {e}"),
})?;
let mut form_params: Vec<(String, String)> = params
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
if let Some(t) = &token {
form_params.push(("token".to_string(), t.clone()));
}
tracing::debug!(%url, "sending API request");
self.http
.post(url)
.form(&form_params)
.send()
.await?
.error_for_status()
.map_err(Error::Http)
}
async fn do_send_multipart(
&self,
path: &str,
form: multipart::Form,
) -> Result<reqwest::Response, Error> {
let token = self.current_token().await;
if token.is_none() {
return Err(Error::NotAuthenticated);
}
let url = self.base_url.join(path).map_err(|e| Error::Config {
reason: format!("invalid API path '{path}': {e}"),
})?;
let form = if let Some(t) = &token {
form.text("token", t.clone())
} else {
form
};
tracing::debug!(%url, "sending multipart API request");
self.http
.post(url)
.multipart(form)
.send()
.await?
.error_for_status()
.map_err(Error::Http)
}
async fn acquire_rate_limit(&self) {
if let Some(rl) = &self.rate_limiter {
rl.acquire().await;
}
}
#[instrument(skip(self, params), fields(path))]
pub(crate) async fn request<T: DeserializeOwned>(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<T, Error> {
self.with_reauth_retry(|| self.do_request::<T>(path, params))
.await
}
#[instrument(skip(self, params), fields(path))]
pub(crate) async fn request_unit(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<(), Error> {
self.with_reauth_retry(|| self.do_request_unit(path, params))
.await
}
async fn with_reauth_retry<T, Fut>(&self, execute: impl Fn() -> Fut) -> Result<T, Error>
where
Fut: Future<Output = Result<T, Error>>,
{
self.acquire_rate_limit().await;
let mut result = execute().await;
if matches!(&result, Err(Error::InvalidToken)) && self.auto_reauth {
if let Some(creds) = &self.credentials {
tracing::info!("token expired, attempting re-authentication");
self.do_login(&creds.username, &creds.password, creds.totp.as_deref())
.await?;
result = execute().await;
}
}
if let Some(ref policy) = self.retry_policy {
if let Err(ref e) = result {
if e.is_retryable() && policy.max_attempts > 0 {
let started = Instant::now();
for attempt in 1..=policy.max_attempts {
let delay = policy.compute_delay(attempt - 1);
if started.elapsed() + delay > Duration::from_secs(30) {
break;
}
tracing::warn!(
attempt,
delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
"retrying after transient error"
);
tokio::time::sleep(delay).await;
result = execute().await;
if result.is_ok() || !result.as_ref().err().is_some_and(Error::is_retryable)
{
break;
}
}
}
}
}
result
}
async fn do_request<T: DeserializeOwned>(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<T, Error> {
let response = self.do_send_form(path, params).await?;
let http_status = response.status().as_u16();
let mut api_response: ApiResponse<T> = response.json().await?;
api_response.http_status = Some(http_status);
api_response.path = path.to_string();
api_response.params = filter_sensitive_params(params);
api_response.into_result()
}
async fn do_request_unit(&self, path: &str, params: &[(&str, &str)]) -> Result<(), Error> {
let response = self.do_send_form(path, params).await?;
let http_status = response.status().as_u16();
let mut api_response: ApiResponse<serde_json::Value> = response.json().await?;
api_response.http_status = Some(http_status);
api_response.path = path.to_string();
api_response.params = filter_sensitive_params(params);
api_response.check_status()
}
#[instrument(skip(self, form), fields(path))]
pub(crate) async fn request_multipart_unit(
&self,
path: &str,
form: multipart::Form,
) -> Result<(), Error> {
self.acquire_rate_limit().await;
let response = self.do_send_multipart(path, form).await?;
let http_status = response.status().as_u16();
let mut api_response: ApiResponse<serde_json::Value> = response.json().await?;
api_response.http_status = Some(http_status);
api_response.path = path.to_string();
api_response.check_status()
}
#[instrument(skip(self, params), fields(path))]
pub(crate) async fn request_bytes(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<Vec<u8>, Error> {
self.acquire_rate_limit().await;
let response = self.do_send_form(path, params).await?;
let bytes = response.bytes().await?;
Ok(bytes.to_vec())
}
pub(crate) async fn do_login(
&self,
username: &str,
password: &str,
totp: Option<&str>,
) -> Result<(), Error> {
let url = self
.base_url
.join("/api/user/login")
.map_err(|e| Error::Config {
reason: format!("invalid base URL: {e}"),
})?;
let mut form: Vec<(&str, &str)> = vec![("user", username), ("pass", password)];
if let Some(code) = totp {
form.push(("totp", code));
}
tracing::info!("authenticating as {}", username);
let response = self
.http
.post(url)
.form(&form)
.send()
.await?
.error_for_status()
.map_err(Error::Http)?;
let body: serde_json::Value = response.json().await?;
let login_params = RequestParams(vec![("user".to_string(), username.to_string())]);
match body.get("status").and_then(|s| s.as_str()) {
Some("ok") => {
let token =
body.get("token")
.and_then(|t| t.as_str())
.ok_or_else(|| Error::Server {
message: "login response missing token".to_string(),
status_code: None,
path: "/api/user/login".to_string(),
params: login_params.clone(),
})?;
let mut guard = self.token.write().await;
*guard = Some(token.to_owned());
Ok(())
}
Some("error") => Err(Error::Authentication {
message: body
.get("errorMessage")
.and_then(|m| m.as_str())
.unwrap_or("unknown error")
.to_string(),
}),
Some("invalid-token") => Err(Error::InvalidToken),
Some("2fa-required") => Err(Error::TwoFactorRequired),
_ => Err(Error::Server {
message: "unexpected login response".to_string(),
status_code: None,
path: "/api/user/login".to_string(),
params: login_params,
}),
}
}
}
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Default)]
pub struct ClientBuilder {
base_url: Option<String>,
token: Option<String>,
username: Option<String>,
password: Option<String>,
auto_reauth: bool,
request_timeout: Option<Duration>,
connect_timeout: Option<Duration>,
retry_policy: Option<RetryPolicy>,
rate_limit: Option<f64>,
pool_max_idle: Option<usize>,
pool_idle_timeout: Option<Duration>,
}
impl ClientBuilder {
#[must_use]
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
#[must_use]
pub fn token(mut self, token: impl Into<String>) -> Self {
self.token = Some(token.into());
self
}
#[must_use]
pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
self.username = Some(username.into());
self.password = Some(password.into());
self
}
#[must_use]
pub fn auto_reauth(mut self, enabled: bool) -> Self {
self.auto_reauth = enabled;
self
}
#[must_use]
pub fn request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = Some(timeout);
self
}
#[must_use]
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = Some(timeout);
self
}
#[must_use]
pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
self.retry_policy = Some(policy);
self
}
#[must_use]
pub fn rate_limit(mut self, requests_per_second: f64) -> Self {
self.rate_limit = Some(requests_per_second);
self
}
#[must_use]
pub fn pool_max_idle(mut self, max_idle: usize) -> Self {
self.pool_max_idle = Some(max_idle);
self
}
#[must_use]
pub fn pool_idle_timeout(mut self, timeout: Duration) -> Self {
self.pool_idle_timeout = Some(timeout);
self
}
pub fn build(self) -> Result<Client, Error> {
let base_url_str = self.base_url.ok_or_else(|| Error::Config {
reason: "base_url is required".to_string(),
})?;
let base_url = Url::parse(&base_url_str).map_err(|e| Error::Config {
reason: format!("invalid base_url '{base_url_str}': {e}"),
})?;
let credentials = match (self.username, self.password) {
(Some(u), Some(p)) => Some(Credentials {
username: u,
password: p,
totp: None,
}),
_ => None,
};
if self.auto_reauth && credentials.is_none() {
return Err(Error::Config {
reason: "auto_reauth requires credentials — call .credentials() before .auto_reauth(true)"
.to_string(),
});
}
let token = self.token.map_or_else(
|| Arc::new(RwLock::new(None)),
|t| Arc::new(RwLock::new(Some(t))),
);
let mut http_builder = reqwest::Client::builder()
.timeout(self.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT))
.connect_timeout(self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT));
if let Some(max_idle) = self.pool_max_idle {
http_builder = http_builder.pool_max_idle_per_host(max_idle);
}
if let Some(idle_timeout) = self.pool_idle_timeout {
http_builder = http_builder.pool_idle_timeout(idle_timeout);
}
let http = http_builder.build().map_err(|e| Error::Config {
reason: format!("failed to build HTTP client: {e}"),
})?;
let rate_limiter = self.rate_limit.map(|rps| Arc::new(RateLimiter::new(rps)));
Ok(Client {
base_url,
http,
token,
credentials,
auto_reauth: self.auto_reauth,
retry_policy: self.retry_policy,
rate_limiter,
})
}
}