1use std::{env, fmt, sync::Arc, time::Duration};
9
10use secrecy::{ExposeSecret, SecretString};
11use url::Url;
12
13use crate::{Error, Result};
14
15const API_BASE: &str = "https://api.cloudconvert.com/v2/";
16const SANDBOX_API_BASE: &str = "https://api.sandbox.cloudconvert.com/v2/";
17const SYNC_API_BASE: &str = "https://sync.api.cloudconvert.com/v2/";
18const SANDBOX_SYNC_API_BASE: &str = "https://sync.api.sandbox.cloudconvert.com/v2/";
19
20#[derive(Clone)]
22pub struct ApiKey(Arc<SecretString>);
23
24impl ApiKey {
25 pub fn new(value: impl Into<String>) -> Self {
26 Self(Arc::new(SecretString::from(value.into())))
27 }
28
29 pub(crate) fn expose(&self) -> &str {
30 self.0.expose_secret()
31 }
32
33 pub fn from_env() -> Result<Self> {
35 env::var("CLOUDCONVERT_API_KEY")
36 .map(Self::new)
37 .map_err(|_| Error::MissingEnv("CLOUDCONVERT_API_KEY"))
38 }
39}
40
41impl fmt::Debug for ApiKey {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 f.write_str("ApiKey(REDACTED)")
44 }
45}
46
47#[derive(Clone)]
49pub struct OAuthAccessToken(Arc<SecretString>);
50
51impl OAuthAccessToken {
52 pub fn new(value: impl Into<String>) -> Self {
53 Self(Arc::new(SecretString::from(value.into())))
54 }
55
56 pub(crate) fn expose(&self) -> &str {
57 self.0.expose_secret()
58 }
59
60 pub fn from_env() -> Result<Self> {
61 env::var("CLOUDCONVERT_OAUTH_ACCESS_TOKEN")
62 .map(Self::new)
63 .map_err(|_| Error::MissingEnv("CLOUDCONVERT_OAUTH_ACCESS_TOKEN"))
64 }
65}
66
67impl fmt::Debug for OAuthAccessToken {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 f.write_str("OAuthAccessToken(REDACTED)")
70 }
71}
72
73#[derive(Clone)]
75pub struct OAuthRefreshToken(Arc<SecretString>);
76
77impl OAuthRefreshToken {
78 pub fn new(value: impl Into<String>) -> Self {
79 Self(Arc::new(SecretString::from(value.into())))
80 }
81
82 pub(crate) fn expose(&self) -> &str {
83 self.0.expose_secret()
84 }
85
86 pub fn from_env() -> Result<Self> {
87 env::var("CLOUDCONVERT_OAUTH_REFRESH_TOKEN")
88 .map(Self::new)
89 .map_err(|_| Error::MissingEnv("CLOUDCONVERT_OAUTH_REFRESH_TOKEN"))
90 }
91}
92
93impl fmt::Debug for OAuthRefreshToken {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 f.write_str("OAuthRefreshToken(REDACTED)")
96 }
97}
98
99#[derive(Clone)]
101pub struct OAuthClientSecret(Arc<SecretString>);
102
103impl OAuthClientSecret {
104 pub fn new(value: impl Into<String>) -> Self {
105 Self(Arc::new(SecretString::from(value.into())))
106 }
107
108 pub(crate) fn expose(&self) -> &str {
109 self.0.expose_secret()
110 }
111
112 pub fn from_env() -> Result<Self> {
113 env::var("CLOUDCONVERT_OAUTH_CLIENT_SECRET")
114 .map(Self::new)
115 .map_err(|_| Error::MissingEnv("CLOUDCONVERT_OAUTH_CLIENT_SECRET"))
116 }
117}
118
119impl fmt::Debug for OAuthClientSecret {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 f.write_str("OAuthClientSecret(REDACTED)")
122 }
123}
124
125#[derive(Clone)]
126pub(crate) enum BearerCredential {
127 ApiKey(ApiKey),
128 OAuthAccessToken(OAuthAccessToken),
129}
130
131impl BearerCredential {
132 pub(crate) fn expose(&self) -> &str {
133 match self {
134 Self::ApiKey(api_key) => api_key.expose(),
135 Self::OAuthAccessToken(access_token) => access_token.expose(),
136 }
137 }
138}
139
140impl fmt::Debug for BearerCredential {
141 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142 match self {
143 Self::ApiKey(api_key) => api_key.fmt(f),
144 Self::OAuthAccessToken(access_token) => access_token.fmt(f),
145 }
146 }
147}
148
149#[derive(Clone)]
151pub struct SigningSecret(Arc<SecretString>);
152
153impl SigningSecret {
154 pub fn new(value: impl Into<String>) -> Self {
155 Self(Arc::new(SecretString::from(value.into())))
156 }
157
158 pub(crate) fn expose(&self) -> &str {
159 self.0.expose_secret()
160 }
161}
162
163impl fmt::Debug for SigningSecret {
164 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165 f.write_str("SigningSecret(REDACTED)")
166 }
167}
168
169#[derive(Clone, Debug, Eq, PartialEq)]
171#[non_exhaustive]
172pub enum Region {
173 EuCentral,
174 UsEast,
175 Custom(String),
176}
177
178impl Region {
179 fn prefix(&self) -> Result<&str> {
180 match self {
181 Self::EuCentral => Ok("eu-central"),
182 Self::UsEast => Ok("us-east"),
183 Self::Custom(region) => validate_region_prefix(region),
184 }
185 }
186}
187
188#[derive(Clone)]
190pub struct CloudConvertConfig {
191 pub(crate) credential: BearerCredential,
192 pub(crate) api_base_url: Url,
193 pub(crate) sync_base_url: Url,
194 pub(crate) sandbox: bool,
195 pub(crate) region: Option<Region>,
196 #[cfg(feature = "retry")]
197 pub(crate) retry_policy: Option<RetryPolicy>,
198}
199
200impl CloudConvertConfig {
201 pub fn api_base_url(&self) -> &Url {
202 &self.api_base_url
203 }
204
205 pub fn sync_base_url(&self) -> &Url {
206 &self.sync_base_url
207 }
208
209 pub fn sandbox(&self) -> bool {
210 self.sandbox
211 }
212
213 pub fn region(&self) -> Option<&Region> {
214 self.region.as_ref()
215 }
216}
217
218impl fmt::Debug for CloudConvertConfig {
219 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220 let mut debug = f.debug_struct("CloudConvertConfig");
221 debug
222 .field("credential", &self.credential)
223 .field("api_base_url", &self.api_base_url)
224 .field("sync_base_url", &self.sync_base_url)
225 .field("sandbox", &self.sandbox)
226 .field("region", &self.region);
227 #[cfg(feature = "retry")]
228 debug.field("retry_policy", &self.retry_policy);
229 debug.finish()
230 }
231}
232
233#[derive(Clone, Debug, Default)]
235#[non_exhaustive]
236pub struct TransportConfig {
237 request_timeout: Option<Duration>,
238 connect_timeout: Option<Duration>,
239 pool_idle_timeout: Option<Duration>,
240 user_agent: Option<String>,
241}
242
243impl TransportConfig {
244 pub fn request_timeout_value(&self) -> Option<Duration> {
245 self.request_timeout
246 }
247
248 pub fn request_timeout(mut self, request_timeout: Duration) -> Self {
249 self.request_timeout = Some(request_timeout);
250 self
251 }
252
253 pub fn connect_timeout_value(&self) -> Option<Duration> {
254 self.connect_timeout
255 }
256
257 pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self {
258 self.connect_timeout = Some(connect_timeout);
259 self
260 }
261
262 pub fn pool_idle_timeout_value(&self) -> Option<Duration> {
263 self.pool_idle_timeout
264 }
265
266 pub fn pool_idle_timeout(mut self, pool_idle_timeout: Duration) -> Self {
267 self.pool_idle_timeout = Some(pool_idle_timeout);
268 self
269 }
270
271 pub fn user_agent_value(&self) -> Option<&str> {
272 self.user_agent.as_deref()
273 }
274
275 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
276 self.user_agent = Some(user_agent.into());
277 self
278 }
279}
280
281#[cfg(feature = "retry")]
285#[derive(Clone, Debug)]
286#[non_exhaustive]
287pub struct RetryPolicy {
288 max_attempts: u32,
289 initial_delay: Duration,
290 max_delay: Duration,
291 backoff_factor: f64,
292 respect_retry_after: bool,
293}
294
295#[cfg(feature = "retry")]
296impl RetryPolicy {
297 pub fn new(max_attempts: u32) -> Self {
298 Self {
299 max_attempts: max_attempts.max(1),
300 ..Self::default()
301 }
302 }
303
304 pub fn max_attempts_value(&self) -> u32 {
305 self.max_attempts
306 }
307
308 pub fn max_attempts(mut self, max_attempts: u32) -> Self {
309 self.max_attempts = max_attempts.max(1);
310 self
311 }
312
313 pub fn initial_delay_value(&self) -> Duration {
314 self.initial_delay
315 }
316
317 pub fn initial_delay(mut self, initial_delay: Duration) -> Self {
318 self.initial_delay = initial_delay;
319 self
320 }
321
322 pub fn max_delay_value(&self) -> Duration {
323 self.max_delay
324 }
325
326 pub fn max_delay(mut self, max_delay: Duration) -> Self {
327 self.max_delay = max_delay;
328 self
329 }
330
331 pub fn backoff_factor_value(&self) -> f64 {
332 self.backoff_factor
333 }
334
335 pub fn backoff_factor(mut self, backoff_factor: f64) -> Self {
336 self.backoff_factor = if backoff_factor.is_finite() {
337 backoff_factor.max(1.0)
338 } else {
339 1.0
340 };
341 self
342 }
343
344 pub fn respect_retry_after_value(&self) -> bool {
345 self.respect_retry_after
346 }
347
348 pub fn respect_retry_after(mut self, respect_retry_after: bool) -> Self {
349 self.respect_retry_after = respect_retry_after;
350 self
351 }
352}
353
354#[cfg(feature = "retry")]
355impl Default for RetryPolicy {
356 fn default() -> Self {
357 Self {
358 max_attempts: 3,
359 initial_delay: Duration::from_millis(250),
360 max_delay: Duration::from_secs(10),
361 backoff_factor: 2.0,
362 respect_retry_after: true,
363 }
364 }
365}
366
367#[derive(Clone, Debug)]
369pub struct ClientBuilder {
370 credential: BearerCredential,
371 sandbox: bool,
372 region: Option<Region>,
373 api_base_url: Option<Url>,
374 sync_base_url: Option<Url>,
375 http_client: Option<reqwest::Client>,
376 redirectless_http_client: Option<reqwest::Client>,
377 transport_config: Option<TransportConfig>,
378 #[cfg(feature = "retry")]
379 retry_policy: Option<RetryPolicy>,
380}
381
382impl ClientBuilder {
383 pub fn new(api_key: ApiKey) -> Self {
384 Self::with_credential(BearerCredential::ApiKey(api_key))
385 }
386
387 pub fn new_with_access_token(access_token: OAuthAccessToken) -> Self {
388 Self::with_credential(BearerCredential::OAuthAccessToken(access_token))
389 }
390
391 pub(crate) fn with_credential(credential: BearerCredential) -> Self {
392 Self {
393 credential,
394 sandbox: false,
395 region: None,
396 api_base_url: None,
397 sync_base_url: None,
398 http_client: None,
399 redirectless_http_client: None,
400 transport_config: None,
401 #[cfg(feature = "retry")]
402 retry_policy: None,
403 }
404 }
405
406 pub fn sandbox(mut self, sandbox: bool) -> Self {
408 self.sandbox = sandbox;
409 self
410 }
411
412 pub fn region(mut self, region: Region) -> Self {
414 self.region = Some(region);
415 self
416 }
417
418 pub fn with_base_urls(mut self, api_base_url: Url, sync_base_url: Url) -> Self {
419 self.api_base_url = Some(api_base_url);
420 self.sync_base_url = Some(sync_base_url);
421 self
422 }
423
424 pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
425 self.http_client = Some(http_client);
426 self
427 }
428
429 pub fn http_clients(
430 mut self,
431 http_client: reqwest::Client,
432 redirectless_http_client: reqwest::Client,
433 ) -> Self {
434 self.http_client = Some(http_client);
435 self.redirectless_http_client = Some(redirectless_http_client);
436 self
437 }
438
439 pub fn transport_config(mut self, transport_config: TransportConfig) -> Self {
440 self.transport_config = Some(transport_config);
441 self
442 }
443
444 #[cfg(feature = "retry")]
445 pub fn retry_policy(mut self, retry_policy: RetryPolicy) -> Self {
446 self.retry_policy = Some(retry_policy);
447 self
448 }
449
450 pub fn build(self) -> Result<crate::CloudConvertClient> {
452 let api_base_url = ensure_trailing_slash(match self.api_base_url {
453 Some(url) => url,
454 None => default_api_url(self.sandbox, self.region.as_ref())?,
455 });
456 let sync_base_url = ensure_trailing_slash(match self.sync_base_url {
457 Some(url) => url,
458 None => default_sync_url(self.sandbox, self.region.as_ref())?,
459 });
460
461 let config = CloudConvertConfig {
462 credential: self.credential,
463 api_base_url,
464 sync_base_url,
465 sandbox: self.sandbox,
466 region: self.region,
467 #[cfg(feature = "retry")]
468 retry_policy: self.retry_policy,
469 };
470
471 let (http_client, redirectless_http_client) = match (
472 self.http_client,
473 self.redirectless_http_client,
474 self.transport_config,
475 ) {
476 (Some(http_client), Some(redirectless_http_client), _) => {
477 (http_client, redirectless_http_client)
478 }
479 (Some(http_client), None, _) => (http_client, redirectless_client(None)?),
480 (None, Some(redirectless_http_client), transport_config) => (
481 http_client(transport_config.as_ref())?,
482 redirectless_http_client,
483 ),
484 (None, None, transport_config) => (
485 http_client(transport_config.as_ref())?,
486 redirectless_client(transport_config.as_ref())?,
487 ),
488 };
489
490 crate::CloudConvertClient::from_parts(config, http_client, redirectless_http_client)
491 }
492}
493
494fn http_client(transport_config: Option<&TransportConfig>) -> Result<reqwest::Client> {
495 http_client_builder(transport_config)
496 .build()
497 .map_err(Error::Http)
498}
499
500fn redirectless_client(transport_config: Option<&TransportConfig>) -> Result<reqwest::Client> {
501 http_client_builder(transport_config)
502 .redirect(reqwest::redirect::Policy::none())
503 .build()
504 .map_err(Error::Http)
505}
506
507fn http_client_builder(transport_config: Option<&TransportConfig>) -> reqwest::ClientBuilder {
508 let mut builder = reqwest::Client::builder();
509
510 if let Some(transport_config) = transport_config {
511 if let Some(timeout) = transport_config.request_timeout {
512 builder = builder.timeout(timeout);
513 }
514 if let Some(timeout) = transport_config.connect_timeout {
515 builder = builder.connect_timeout(timeout);
516 }
517 if let Some(timeout) = transport_config.pool_idle_timeout {
518 builder = builder.pool_idle_timeout(timeout);
519 }
520 if let Some(user_agent) = &transport_config.user_agent {
521 builder = builder.user_agent(user_agent);
522 }
523 }
524
525 builder
526}
527
528fn ensure_trailing_slash(mut url: Url) -> Url {
529 if !url.path().ends_with('/') {
530 let path = format!("{}/", url.path());
531 url.set_path(&path);
532 }
533 url
534}
535
536fn default_api_url(sandbox: bool, region: Option<&Region>) -> Result<Url> {
537 if sandbox {
538 return Url::parse(SANDBOX_API_BASE).map_err(Error::Url);
539 }
540
541 match region {
542 Some(region) => {
543 let prefix = region.prefix()?;
544 Url::parse(&format!("https://{prefix}.api.cloudconvert.com/v2/")).map_err(Error::Url)
545 }
546 None => Url::parse(API_BASE).map_err(Error::Url),
547 }
548}
549
550fn default_sync_url(sandbox: bool, region: Option<&Region>) -> Result<Url> {
551 if sandbox {
552 return Url::parse(SANDBOX_SYNC_API_BASE).map_err(Error::Url);
553 }
554
555 match region {
556 Some(region) => {
557 let prefix = region.prefix()?;
558 Url::parse(&format!("https://{prefix}.sync.api.cloudconvert.com/v2/"))
559 .map_err(Error::Url)
560 }
561 None => Url::parse(SYNC_API_BASE).map_err(Error::Url),
562 }
563}
564
565fn validate_region_prefix(prefix: &str) -> Result<&str> {
566 let valid = !prefix.is_empty()
567 && !prefix.starts_with('-')
568 && !prefix.ends_with('-')
569 && prefix
570 .bytes()
571 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-');
572
573 if valid {
574 Ok(prefix)
575 } else {
576 Err(Error::InvalidRegion)
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use super::validate_region_prefix;
583
584 #[test]
585 fn validates_custom_region_prefixes() {
586 assert_eq!(
587 validate_region_prefix("ap-southeast").unwrap(),
588 "ap-southeast"
589 );
590 assert!(validate_region_prefix("attacker.test").is_err());
591 assert!(validate_region_prefix("attacker/../api").is_err());
592 assert!(validate_region_prefix("-us-east").is_err());
593 assert!(validate_region_prefix("us-east-").is_err());
594 }
595}