Skip to main content

cloudconvert_sdk/
config.rs

1//! Client configuration: credentials, regions, transport, and optional retry.
2//!
3//! [`ClientBuilder`] resolves API and sync base URLs from sandbox and region
4//! settings, then constructs a [`CloudConvertClient`]. Secret-bearing types
5//! redact values in `Debug` output and expose raw strings only through
6//! crate-internal `expose` helpers used for Authorization headers.
7
8use 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/// CloudConvert API key used as a bearer token.
21#[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    /// Reads `CLOUDCONVERT_API_KEY` from the process environment.
34    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/// OAuth access token used as a bearer token for API calls.
48#[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/// OAuth refresh token exchanged for a new access token.
74#[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/// OAuth client secret used during authorization-code and refresh flows.
100#[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/// Shared secret for signing job URLs and webhook payloads.
150#[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/// Regional API hostname prefix for non-sandbox clients.
170#[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/// Resolved client settings produced by [`ClientBuilder::build`].
189#[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/// Optional `reqwest` timeouts and user agent applied when building HTTP clients.
234#[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/// Retry policy for idempotent API requests when the `retry` feature is enabled.
282///
283/// Non-idempotent methods such as `POST` are never retried.
284#[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/// Configures credentials, base URLs, transport, and optional retry before build.
368#[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    /// Routes requests to the CloudConvert sandbox API hosts.
407    pub fn sandbox(mut self, sandbox: bool) -> Self {
408        self.sandbox = sandbox;
409        self
410    }
411
412    /// Selects a regional API hostname prefix for production requests.
413    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    /// Builds a [`CloudConvertClient`] with resolved base URLs and HTTP clients.
451    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}