use std::{fmt, time::Duration};
#[cfg(any(feature = "async", feature = "blocking"))]
use crate::{Error, Result};
pub const DEFAULT_ASYNC_BASE_URL: &str = "https://api.ez-captcha.com";
pub const DEFAULT_SYNC_BASE_URL: &str = "https://sync.ez-captcha.com";
pub const DEFAULT_CLIENT_KEY_ENV: &str = "EZCAPSOLVER_API_KEY";
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
pub const DEFAULT_SYNC_TIMEOUT: Duration = Duration::from_secs(240);
pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(3);
pub const DEFAULT_MAX_POLL_ATTEMPTS: usize = 50;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct PollingConfig {
pub interval: Duration,
pub max_attempts: usize,
}
impl Default for PollingConfig {
fn default() -> Self {
Self {
interval: DEFAULT_POLL_INTERVAL,
max_attempts: DEFAULT_MAX_POLL_ATTEMPTS,
}
}
}
impl PollingConfig {
#[must_use]
pub const fn new(interval: Duration, max_attempts: usize) -> Self {
Self {
interval,
max_attempts,
}
}
#[cfg(any(feature = "async", feature = "blocking"))]
pub(crate) fn validate(self) -> Result<()> {
if self.interval.is_zero() {
return Err(Error::config("polling interval must be greater than zero"));
}
if self.max_attempts == 0 {
return Err(Error::config(
"maximum polling attempts must be greater than zero",
));
}
Ok(())
}
}
#[derive(Clone, PartialEq, Eq, bon::Builder)]
#[builder(on(String, into))]
pub struct ClientConfig {
client_key: Option<String>,
#[builder(default = DEFAULT_ASYNC_BASE_URL.to_owned())]
pub async_base_url: String,
#[builder(default = DEFAULT_SYNC_BASE_URL.to_owned())]
pub sync_base_url: String,
#[builder(default = DEFAULT_TIMEOUT)]
pub timeout: Duration,
#[builder(default = DEFAULT_SYNC_TIMEOUT)]
pub sync_timeout: Duration,
#[builder(default)]
pub polling: PollingConfig,
pub app_id: Option<i32>,
pub proxy: Option<String>,
#[builder(default = default_user_agent())]
pub user_agent: String,
}
fn default_user_agent() -> String {
concat!("ezcapsolver-rs/", env!("CARGO_PKG_VERSION")).to_owned()
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
client_key: None,
async_base_url: DEFAULT_ASYNC_BASE_URL.to_owned(),
sync_base_url: DEFAULT_SYNC_BASE_URL.to_owned(),
timeout: DEFAULT_TIMEOUT,
sync_timeout: DEFAULT_SYNC_TIMEOUT,
polling: PollingConfig::default(),
app_id: None,
proxy: None,
user_agent: default_user_agent(),
}
}
}
impl ClientConfig {
#[must_use]
pub fn with_client_key(mut self, value: impl Into<String>) -> Self {
self.client_key = Some(value.into());
self
}
#[must_use]
pub fn with_base_urls(
mut self,
async_base_url: impl Into<String>,
sync_base_url: impl Into<String>,
) -> Self {
let async_base_url = async_base_url.into();
if !async_base_url.is_empty() {
self.async_base_url = async_base_url;
}
let sync_base_url = sync_base_url.into();
if !sync_base_url.is_empty() {
self.sync_base_url = sync_base_url;
}
self
}
#[must_use]
pub const fn with_timeout(mut self, value: Duration) -> Self {
self.timeout = value;
self
}
#[must_use]
pub const fn with_sync_timeout(mut self, value: Duration) -> Self {
self.sync_timeout = value;
self
}
#[must_use]
pub const fn with_polling(mut self, value: PollingConfig) -> Self {
self.polling = value;
self
}
#[must_use]
pub const fn with_app_id(mut self, value: i32) -> Self {
self.app_id = Some(value);
self
}
#[must_use]
pub fn with_proxy(mut self, value: impl Into<String>) -> Self {
self.proxy = Some(value.into());
self
}
#[must_use]
pub fn with_user_agent(mut self, value: impl Into<String>) -> Self {
self.user_agent = value.into();
self
}
#[cfg(any(feature = "async", feature = "blocking"))]
pub(crate) fn validate(&self) -> Result<()> {
if self.async_base_url.trim().is_empty() || self.sync_base_url.trim().is_empty() {
return Err(Error::config("base URLs must not be blank"));
}
if self.timeout.is_zero() {
return Err(Error::config("timeout must be greater than zero"));
}
if self.sync_timeout.is_zero() {
return Err(Error::config("sync timeout must be greater than zero"));
}
self.polling.validate()
}
#[cfg(any(feature = "async", feature = "blocking"))]
pub(crate) fn resolve_client_key(&self) -> Result<String> {
let client_key = match &self.client_key {
Some(client_key) => client_key.clone(),
None => std::env::var(DEFAULT_CLIENT_KEY_ENV).map_err(|source| {
Error::config(format!(
"client key environment variable `{DEFAULT_CLIENT_KEY_ENV}` is unavailable: {source}"
))
})?,
};
if client_key.trim().is_empty() {
return Err(Error::config("client key must not be blank"));
}
Ok(client_key)
}
}
impl fmt::Debug for ClientConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientConfig")
.field(
"client_key",
&self.client_key.as_ref().map(|_| "[REDACTED]"),
)
.field("async_base_url", &self.async_base_url)
.field("sync_base_url", &self.sync_base_url)
.field("timeout", &self.timeout)
.field("sync_timeout", &self.sync_timeout)
.field("polling", &self.polling)
.field("app_id", &self.app_id)
.field("proxy", &self.proxy.as_ref().map(|_| "[REDACTED]"))
.field("user_agent", &self.user_agent)
.finish()
}
}
impl From<String> for ClientConfig {
fn from(client_key: String) -> Self {
Self::default().with_client_key(client_key)
}
}
impl From<&str> for ClientConfig {
fn from(client_key: &str) -> Self {
Self::from(client_key.to_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn synchronous_and_asynchronous_timeouts_have_distinct_defaults() {
let config = ClientConfig::default();
assert_eq!(config.timeout, Duration::from_secs(30));
assert!(config.sync_timeout > Duration::from_secs(180));
assert_eq!(config.sync_timeout, Duration::from_secs(240));
assert_ne!(config.timeout, config.sync_timeout);
}
#[test]
fn polling_defaults_match_the_specified_budget() {
let polling = PollingConfig::default();
assert_eq!(polling.interval, Duration::from_secs(3));
assert_eq!(polling.max_attempts, 50);
}
#[test]
fn debug_output_redacts_credentials() {
let config = ClientConfig::default()
.with_client_key("secret-client-key")
.with_proxy("http://user:password@127.0.0.1:8080");
let output = format!("{config:?}");
assert!(!output.contains("secret-client-key"));
assert!(!output.contains("password"));
assert!(output.contains("[REDACTED]"));
}
}