1use std::{fmt, time::Duration};
2
3#[cfg(any(feature = "async", feature = "blocking"))]
4use crate::{Error, Result};
5
6pub const DEFAULT_ASYNC_BASE_URL: &str = "https://api.ez-captcha.com";
8
9pub const DEFAULT_SYNC_BASE_URL: &str = "https://sync.ez-captcha.com";
11
12pub const DEFAULT_CLIENT_KEY_ENV: &str = "EZCAPSOLVER_API_KEY";
14
15pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
17
18pub const DEFAULT_SYNC_TIMEOUT: Duration = Duration::from_secs(240);
25
26pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(3);
28
29pub const DEFAULT_MAX_POLL_ATTEMPTS: usize = 50;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38#[non_exhaustive]
39pub struct PollingConfig {
40 pub interval: Duration,
46 pub max_attempts: usize,
48}
49
50impl Default for PollingConfig {
51 fn default() -> Self {
52 Self {
53 interval: DEFAULT_POLL_INTERVAL,
54 max_attempts: DEFAULT_MAX_POLL_ATTEMPTS,
55 }
56 }
57}
58
59impl PollingConfig {
60 #[must_use]
62 pub const fn new(interval: Duration, max_attempts: usize) -> Self {
63 Self {
64 interval,
65 max_attempts,
66 }
67 }
68
69 #[cfg(any(feature = "async", feature = "blocking"))]
70 pub(crate) fn validate(self) -> Result<()> {
71 if self.interval.is_zero() {
72 return Err(Error::config("polling interval must be greater than zero"));
73 }
74 if self.max_attempts == 0 {
75 return Err(Error::config(
76 "maximum polling attempts must be greater than zero",
77 ));
78 }
79 Ok(())
80 }
81}
82
83#[derive(Clone, PartialEq, Eq, bon::Builder)]
101#[builder(on(String, into))]
102pub struct ClientConfig {
103 client_key: Option<String>,
105 #[builder(default = DEFAULT_ASYNC_BASE_URL.to_owned())]
111 pub async_base_url: String,
112 #[builder(default = DEFAULT_SYNC_BASE_URL.to_owned())]
114 pub sync_base_url: String,
115 #[builder(default = DEFAULT_TIMEOUT)]
120 pub timeout: Duration,
121 #[builder(default = DEFAULT_SYNC_TIMEOUT)]
129 pub sync_timeout: Duration,
130 #[builder(default)]
132 pub polling: PollingConfig,
133 pub app_id: Option<i32>,
135 pub proxy: Option<String>,
137 #[builder(default = default_user_agent())]
139 pub user_agent: String,
140}
141
142fn default_user_agent() -> String {
144 concat!("ezcapsolver-rs/", env!("CARGO_PKG_VERSION")).to_owned()
145}
146
147impl Default for ClientConfig {
148 fn default() -> Self {
149 Self {
150 client_key: None,
151 async_base_url: DEFAULT_ASYNC_BASE_URL.to_owned(),
152 sync_base_url: DEFAULT_SYNC_BASE_URL.to_owned(),
153 timeout: DEFAULT_TIMEOUT,
154 sync_timeout: DEFAULT_SYNC_TIMEOUT,
155 polling: PollingConfig::default(),
156 app_id: None,
157 proxy: None,
158 user_agent: default_user_agent(),
159 }
160 }
161}
162
163impl ClientConfig {
164 #[must_use]
166 pub fn with_client_key(mut self, value: impl Into<String>) -> Self {
167 self.client_key = Some(value.into());
168 self
169 }
170
171 #[must_use]
174 pub fn with_base_urls(
175 mut self,
176 async_base_url: impl Into<String>,
177 sync_base_url: impl Into<String>,
178 ) -> Self {
179 let async_base_url = async_base_url.into();
180 if !async_base_url.is_empty() {
181 self.async_base_url = async_base_url;
182 }
183 let sync_base_url = sync_base_url.into();
184 if !sync_base_url.is_empty() {
185 self.sync_base_url = sync_base_url;
186 }
187 self
188 }
189
190 #[must_use]
192 pub const fn with_timeout(mut self, value: Duration) -> Self {
193 self.timeout = value;
194 self
195 }
196
197 #[must_use]
199 pub const fn with_sync_timeout(mut self, value: Duration) -> Self {
200 self.sync_timeout = value;
201 self
202 }
203
204 #[must_use]
206 pub const fn with_polling(mut self, value: PollingConfig) -> Self {
207 self.polling = value;
208 self
209 }
210
211 #[must_use]
213 pub const fn with_app_id(mut self, value: i32) -> Self {
214 self.app_id = Some(value);
215 self
216 }
217
218 #[must_use]
220 pub fn with_proxy(mut self, value: impl Into<String>) -> Self {
221 self.proxy = Some(value.into());
222 self
223 }
224
225 #[must_use]
227 pub fn with_user_agent(mut self, value: impl Into<String>) -> Self {
228 self.user_agent = value.into();
229 self
230 }
231
232 #[cfg(any(feature = "async", feature = "blocking"))]
233 pub(crate) fn validate(&self) -> Result<()> {
234 if self.async_base_url.trim().is_empty() || self.sync_base_url.trim().is_empty() {
235 return Err(Error::config("base URLs must not be blank"));
236 }
237 if self.timeout.is_zero() {
238 return Err(Error::config("timeout must be greater than zero"));
239 }
240 if self.sync_timeout.is_zero() {
241 return Err(Error::config("sync timeout must be greater than zero"));
242 }
243 self.polling.validate()
244 }
245
246 #[cfg(any(feature = "async", feature = "blocking"))]
247 pub(crate) fn resolve_client_key(&self) -> Result<String> {
248 let client_key = match &self.client_key {
249 Some(client_key) => client_key.clone(),
250 None => std::env::var(DEFAULT_CLIENT_KEY_ENV).map_err(|source| {
251 Error::config(format!(
252 "client key environment variable `{DEFAULT_CLIENT_KEY_ENV}` is unavailable: {source}"
253 ))
254 })?,
255 };
256 if client_key.trim().is_empty() {
257 return Err(Error::config("client key must not be blank"));
258 }
259 Ok(client_key)
260 }
261}
262
263impl fmt::Debug for ClientConfig {
264 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
265 formatter
266 .debug_struct("ClientConfig")
267 .field(
268 "client_key",
269 &self.client_key.as_ref().map(|_| "[REDACTED]"),
270 )
271 .field("async_base_url", &self.async_base_url)
272 .field("sync_base_url", &self.sync_base_url)
273 .field("timeout", &self.timeout)
274 .field("sync_timeout", &self.sync_timeout)
275 .field("polling", &self.polling)
276 .field("app_id", &self.app_id)
277 .field("proxy", &self.proxy.as_ref().map(|_| "[REDACTED]"))
278 .field("user_agent", &self.user_agent)
279 .finish()
280 }
281}
282
283impl From<String> for ClientConfig {
284 fn from(client_key: String) -> Self {
285 Self::default().with_client_key(client_key)
286 }
287}
288
289impl From<&str> for ClientConfig {
290 fn from(client_key: &str) -> Self {
291 Self::from(client_key.to_owned())
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn synchronous_and_asynchronous_timeouts_have_distinct_defaults() {
301 let config = ClientConfig::default();
302
303 assert_eq!(config.timeout, Duration::from_secs(30));
307 assert!(config.sync_timeout > Duration::from_secs(180));
310 assert_eq!(config.sync_timeout, Duration::from_secs(240));
311 assert_ne!(config.timeout, config.sync_timeout);
312 }
313
314 #[test]
315 fn polling_defaults_match_the_specified_budget() {
316 let polling = PollingConfig::default();
317
318 assert_eq!(polling.interval, Duration::from_secs(3));
319 assert_eq!(polling.max_attempts, 50);
320 }
321
322 #[test]
323 fn debug_output_redacts_credentials() {
324 let config = ClientConfig::default()
325 .with_client_key("secret-client-key")
326 .with_proxy("http://user:password@127.0.0.1:8080");
327 let output = format!("{config:?}");
328
329 assert!(!output.contains("secret-client-key"));
330 assert!(!output.contains("password"));
331 assert!(output.contains("[REDACTED]"));
332 }
333}