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}