use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use tokio::sync::Mutex;
use tokio::time::Instant;
use crate::errors::{Error, Result};
pub const DEFAULT_BASE_URL: &str = "https://api.xposedornot.com";
pub const PLUS_BASE_URL: &str = "https://plus-api.xposedornot.com";
pub const PASSWORD_BASE_URL: &str = "https://passwords.xposedornot.com/api";
const DEFAULT_TIMEOUT_SECS: u64 = 30;
const DEFAULT_MAX_RETRIES: u32 = 3;
const RATE_LIMIT_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Debug)]
pub(crate) struct RateLimitState {
last_request: Option<Instant>,
}
#[derive(Clone)]
pub struct ClientConfig {
pub base_url: String,
pub plus_base_url: String,
pub password_base_url: String,
pub timeout: Duration,
pub max_retries: u32,
pub api_key: Option<String>,
pub custom_headers: HashMap<String, String>,
}
impl std::fmt::Debug for ClientConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientConfig")
.field("base_url", &self.base_url)
.field("plus_base_url", &self.plus_base_url)
.field("password_base_url", &self.password_base_url)
.field("timeout", &self.timeout)
.field("max_retries", &self.max_retries)
.field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
.field("custom_headers", &self.custom_headers)
.finish()
}
}
#[derive(Clone)]
pub struct ClientBuilder {
base_url: Option<String>,
plus_base_url: Option<String>,
password_base_url: Option<String>,
timeout_secs: Option<u64>,
max_retries: Option<u32>,
api_key: Option<String>,
custom_headers: HashMap<String, String>,
}
impl std::fmt::Debug for ClientBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientBuilder")
.field("base_url", &self.base_url)
.field("plus_base_url", &self.plus_base_url)
.field("password_base_url", &self.password_base_url)
.field("timeout_secs", &self.timeout_secs)
.field("max_retries", &self.max_retries)
.field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
.field("custom_headers", &self.custom_headers)
.finish()
}
}
impl ClientBuilder {
fn new() -> Self {
Self {
base_url: None,
plus_base_url: None,
password_base_url: None,
timeout_secs: None,
max_retries: None,
api_key: None,
custom_headers: HashMap::new(),
}
}
#[must_use]
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
#[must_use]
pub fn plus_base_url(mut self, url: impl Into<String>) -> Self {
self.plus_base_url = Some(url.into());
self
}
#[must_use]
pub fn password_base_url(mut self, url: impl Into<String>) -> Self {
self.password_base_url = Some(url.into());
self
}
#[must_use]
pub fn timeout_secs(mut self, secs: u64) -> Self {
self.timeout_secs = Some(secs);
self
}
#[must_use]
pub fn max_retries(mut self, retries: u32) -> Self {
self.max_retries = Some(retries);
self
}
#[must_use]
pub fn api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self
}
#[must_use]
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.custom_headers.insert(name.into(), value.into());
self
}
pub fn build(self) -> Result<Client> {
let config = ClientConfig {
base_url: self
.base_url
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
plus_base_url: self
.plus_base_url
.unwrap_or_else(|| PLUS_BASE_URL.to_string()),
password_base_url: self
.password_base_url
.unwrap_or_else(|| PASSWORD_BASE_URL.to_string()),
timeout: Duration::from_secs(self.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS)),
max_retries: self.max_retries.unwrap_or(DEFAULT_MAX_RETRIES),
api_key: self.api_key,
custom_headers: self.custom_headers,
};
let mut default_headers = HeaderMap::new();
for (name, value) in &config.custom_headers {
let header_name = HeaderName::try_from(name.as_str()).map_err(|e| {
Error::Validation {
message: format!("invalid header name '{name}': {e}"),
}
})?;
let header_value = HeaderValue::try_from(value.as_str()).map_err(|e| {
Error::Validation {
message: format!("invalid header value for '{name}': {e}"),
}
})?;
default_headers.insert(header_name, header_value);
}
let http_client = reqwest::Client::builder()
.timeout(config.timeout)
.default_headers(default_headers)
.build()?;
Ok(Client {
http: http_client,
config,
rate_limit: Arc::new(Mutex::new(RateLimitState {
last_request: None,
})),
})
}
}
#[derive(Debug, Clone)]
pub struct Client {
pub(crate) http: reqwest::Client,
pub config: ClientConfig,
pub(crate) rate_limit: Arc<Mutex<RateLimitState>>,
}
impl Client {
#[must_use]
pub fn builder() -> ClientBuilder {
ClientBuilder::new()
}
pub fn has_api_key(&self) -> bool {
self.config.api_key.is_some()
}
pub(crate) async fn enforce_rate_limit(&self) {
if self.has_api_key() {
return;
}
let mut state = self.rate_limit.lock().await;
if let Some(last) = state.last_request {
let elapsed = last.elapsed();
if elapsed < RATE_LIMIT_INTERVAL {
let wait = RATE_LIMIT_INTERVAL - elapsed;
drop(state);
tokio::time::sleep(wait).await;
let mut state = self.rate_limit.lock().await;
state.last_request = Some(Instant::now());
} else {
state.last_request = Some(Instant::now());
}
} else {
state.last_request = Some(Instant::now());
}
}
pub(crate) async fn get_with_retry(&self, url: &str) -> Result<reqwest::Response> {
self.enforce_rate_limit().await;
let mut last_error = None;
for attempt in 0..=self.config.max_retries {
let mut request = self.http.get(url);
if let Some(ref api_key) = self.config.api_key {
request = request.header("x-api-key", api_key);
}
let response = request.send().await?;
let status = response.status();
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
if attempt < self.config.max_retries {
let delay = Duration::from_secs(1 << attempt);
tokio::time::sleep(delay).await;
last_error = Some(Error::RateLimit {
message: "too many requests (429)".to_string(),
});
continue;
}
return Err(Error::RateLimit {
message: "too many requests after max retries".to_string(),
});
}
if status == reqwest::StatusCode::NOT_FOUND {
return Err(Error::NotFound {
message: format!("resource not found: {url}"),
});
}
if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
return Err(Error::Authentication {
message: "invalid or missing API key".to_string(),
});
}
if status.is_server_error() || status.is_client_error() {
let body = response.text().await.unwrap_or_default();
return Err(Error::Api {
status_code: status.as_u16(),
message: body,
});
}
return Ok(response);
}
Err(last_error.unwrap_or(Error::Api {
status_code: 429,
message: "rate limited after retries".to_string(),
}))
}
}