Skip to main content

cloudconvert_sdk/
config.rs

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