1#[cfg(feature = "retry")]
11use std::time::Duration;
12use std::{
13 error::Error as StdError,
14 ffi::{OsStr, OsString},
15 path::{Path, PathBuf},
16 sync::Arc,
17 time::{SystemTime, UNIX_EPOCH},
18};
19
20use bytes::Bytes;
21use futures_util::{Stream, StreamExt, TryStream};
22use reqwest::{
23 Body, Method, Response,
24 header::{HeaderMap, LOCATION},
25 multipart,
26};
27use serde::{Serialize, de::DeserializeOwned};
28use serde_json::Value;
29use tokio::io::AsyncWriteExt;
30use url::Url;
31
32use crate::{
33 Result,
34 config::{ApiKey, ClientBuilder, CloudConvertConfig, OAuthAccessToken},
35 error::{ApiErrorBody, Error},
36 jobs::{
37 ApiResponse, DataEnvelope, Job, JobCreateRequest, JobGetQuery, JobListQuery, Page,
38 RateLimit, Task, TaskGetQuery, TaskListQuery, UploadForm,
39 },
40 operations::{Operation, OperationListQuery},
41 resources::{User, Webhook, WebhookCreateRequest, WebhookListQuery},
42 socket::{SocketChannel, SocketSubscription},
43 tasks::TaskRequest,
44};
45
46#[cfg(feature = "socket")]
47use crate::socket::CloudConvertSocket;
48
49#[derive(Clone, Debug)]
51pub struct CloudConvertClient {
52 config: Arc<CloudConvertConfig>,
53 http: reqwest::Client,
54 redirectless_http: reqwest::Client,
56}
57
58impl CloudConvertClient {
59 pub fn builder(api_key: ApiKey) -> ClientBuilder {
61 ClientBuilder::new(api_key)
62 }
63
64 pub fn builder_with_access_token(access_token: OAuthAccessToken) -> ClientBuilder {
66 ClientBuilder::new_with_access_token(access_token)
67 }
68
69 pub(crate) fn from_parts(
70 config: CloudConvertConfig,
71 http: reqwest::Client,
72 redirectless_http: reqwest::Client,
73 ) -> Result<Self> {
74 Ok(Self {
75 config: Arc::new(config),
76 http,
77 redirectless_http,
78 })
79 }
80
81 pub fn config(&self) -> &CloudConvertConfig {
83 &self.config
84 }
85
86 pub fn jobs(&self) -> JobsResource {
88 JobsResource {
89 client: self.clone(),
90 }
91 }
92
93 pub fn tasks(&self) -> TasksResource {
95 TasksResource {
96 client: self.clone(),
97 }
98 }
99
100 pub fn users(&self) -> UsersResource {
102 UsersResource {
103 client: self.clone(),
104 }
105 }
106
107 pub fn webhooks(&self) -> WebhooksResource {
109 WebhooksResource {
110 client: self.clone(),
111 }
112 }
113
114 pub fn operations(&self) -> OperationsResource {
116 OperationsResource {
117 client: self.clone(),
118 }
119 }
120
121 pub fn socket_base_url(&self) -> &'static str {
123 crate::socket::socket_base_url(self.config.sandbox())
124 }
125
126 pub fn socket_subscription(&self, channel: SocketChannel) -> SocketSubscription {
128 SocketSubscription::new(channel.name().into_owned(), self.config.credential.expose())
129 }
130
131 #[cfg(feature = "socket")]
135 pub async fn socket(
136 &self,
137 channels: impl IntoIterator<Item = SocketChannel>,
138 ) -> Result<CloudConvertSocket> {
139 let subscriptions = channels
140 .into_iter()
141 .map(|channel| self.socket_subscription(channel));
142 CloudConvertSocket::connect(self.socket_base_url(), subscriptions).await
143 }
144
145 #[cfg(feature = "socket")]
146 pub async fn socket_with_buffer(
147 &self,
148 channels: impl IntoIterator<Item = SocketChannel>,
149 buffer: usize,
150 ) -> Result<CloudConvertSocket> {
151 let subscriptions = channels
152 .into_iter()
153 .map(|channel| self.socket_subscription(channel));
154 CloudConvertSocket::connect_with_buffer(self.socket_base_url(), subscriptions, buffer).await
155 }
156
157 pub async fn download(&self, url: impl AsRef<str>) -> Result<Bytes> {
159 let response = self.http.get(url.as_ref()).send().await?;
160 Self::ensure_success(response)
161 .await?
162 .bytes()
163 .await
164 .map_err(Error::Http)
165 }
166
167 pub async fn download_stream(
169 &self,
170 url: impl AsRef<str>,
171 ) -> Result<impl Stream<Item = Result<Bytes>>> {
172 let response = self.http.get(url.as_ref()).send().await?;
173 Ok(Self::ensure_success(response)
174 .await?
175 .bytes_stream()
176 .map(|chunk| chunk.map_err(Error::Http)))
177 }
178
179 pub async fn download_to_path(
184 &self,
185 url: impl AsRef<str>,
186 destination: impl AsRef<Path>,
187 ) -> Result<()> {
188 let destination = destination.as_ref();
189 let temp_path = temporary_download_path(destination);
190 self.download_to_temporary_path(url.as_ref(), destination, temp_path)
191 .await
192 }
193
194 pub async fn upload_bytes(
198 &self,
199 task: &Task,
200 filename: impl Into<String>,
201 bytes: impl Into<Bytes>,
202 ) -> Result<()> {
203 self.upload_part(
204 task,
205 "file",
206 multipart::Part::stream(Body::from(bytes.into())).file_name(filename.into()),
207 )
208 .await
209 }
210
211 pub async fn upload_body(
212 &self,
213 task: &Task,
214 filename: impl Into<String>,
215 body: Body,
216 ) -> Result<()> {
217 self.upload_part(
218 task,
219 "file",
220 multipart::Part::stream(body).file_name(filename.into()),
221 )
222 .await
223 }
224
225 pub async fn upload_stream<S, B, E>(
226 &self,
227 task: &Task,
228 filename: impl Into<String>,
229 stream: S,
230 ) -> Result<()>
231 where
232 S: TryStream<Ok = B, Error = E> + Send + 'static,
233 Bytes: From<B>,
234 E: Into<Box<dyn StdError + Send + Sync>>,
235 {
236 self.upload_body(task, filename, Body::wrap_stream(stream))
237 .await
238 }
239
240 pub async fn upload_path(&self, task: &Task, path: impl AsRef<Path>) -> Result<()> {
241 self.upload_part(task, "file", multipart::Part::file(path).await?)
242 .await
243 }
244
245 async fn download_to_temporary_path(
246 &self,
247 url: &str,
248 destination: &Path,
249 temp_path: PathBuf,
250 ) -> Result<()> {
251 let mut temp_file = TemporaryDownload::new(temp_path);
252 let mut stream = self.download_stream(url).await?;
253 let mut file = tokio::fs::File::create(temp_file.path()).await?;
254
255 while let Some(chunk) = stream.next().await {
256 file.write_all(&chunk?).await?;
257 }
258
259 file.flush().await?;
260 drop(file);
261 tokio::fs::rename(temp_file.path(), destination).await?;
262 temp_file.commit();
263 Ok(())
264 }
265
266 async fn upload_part(
267 &self,
268 task: &Task,
269 field_name: &str,
270 part: multipart::Part,
271 ) -> Result<()> {
272 let form = Self::upload_form(task)?;
273 let mut multipart = multipart::Form::new();
274 for (key, value) in &form.parameters {
275 if value.is_null() {
276 continue;
277 }
278 multipart = multipart.text(key.clone(), form_value(value));
279 }
280 multipart = multipart.part(field_name.to_string(), part);
281
282 let response = self
283 .http
284 .post(&form.url)
285 .multipart(multipart)
286 .send()
287 .await?;
288 Self::ensure_success(response).await?;
289 Ok(())
290 }
291
292 fn upload_form(task: &Task) -> Result<&UploadForm> {
293 task.upload_form().ok_or(Error::UploadTaskNotReady)
294 }
295
296 async fn get_response<T>(&self, base: &Url, path: &str) -> Result<ApiResponse<T>>
297 where
298 T: DeserializeOwned,
299 {
300 let url = api_url(base, path)?;
301 let response = self
302 .send_api(|| {
303 self.http
304 .request(Method::GET, url.clone())
305 .bearer_auth(self.config.credential.expose())
306 })
307 .await?;
308 Self::decode_response(response).await
309 }
310
311 async fn get_with_query_response<T, Q>(
312 &self,
313 base: &Url,
314 path: &str,
315 query: &Q,
316 ) -> Result<ApiResponse<T>>
317 where
318 T: DeserializeOwned,
319 Q: Serialize + ?Sized,
320 {
321 let url = api_url(base, path)?;
322 let response = self
323 .send_api(|| {
324 self.http
325 .request(Method::GET, url.clone())
326 .bearer_auth(self.config.credential.expose())
327 .query(query)
328 })
329 .await?;
330 Self::decode_response(response).await
331 }
332
333 async fn post_response<T, B>(&self, base: &Url, path: &str, body: &B) -> Result<ApiResponse<T>>
334 where
335 T: DeserializeOwned,
336 B: Serialize + ?Sized,
337 {
338 let url = api_url(base, path)?;
339 let response = self
340 .send_api(|| {
341 self.http
342 .request(Method::POST, url.clone())
343 .bearer_auth(self.config.credential.expose())
344 .json(body)
345 })
346 .await?;
347 Self::decode_response(response).await
348 }
349
350 async fn delete(&self, base: &Url, path: &str) -> Result<()> {
351 let url = api_url(base, path)?;
352 let response = self
353 .send_api(|| {
354 self.http
355 .request(Method::DELETE, url.clone())
356 .bearer_auth(self.config.credential.expose())
357 })
358 .await?;
359 Self::ensure_success(response).await?;
360 Ok(())
361 }
362
363 async fn get_redirect_location<Q>(&self, base: &Url, path: &str, query: &Q) -> Result<Url>
364 where
365 Q: Serialize + ?Sized,
366 {
367 let url = api_url(base, path)?;
368 let response = self
369 .send_api(|| {
370 self.redirectless_http
371 .request(Method::GET, url.clone())
372 .bearer_auth(self.config.credential.expose())
373 .query(query)
374 })
375 .await?;
376 Self::decode_redirect_location(response).await
377 }
378
379 async fn post_redirect_location<B>(&self, base: &Url, path: &str, body: &B) -> Result<Url>
380 where
381 B: Serialize + ?Sized,
382 {
383 let url = api_url(base, path)?;
384 let response = self
385 .send_api(|| {
386 self.redirectless_http
387 .request(Method::POST, url.clone())
388 .bearer_auth(self.config.credential.expose())
389 .json(body)
390 })
391 .await?;
392 Self::decode_redirect_location(response).await
393 }
394
395 async fn send_api<F>(&self, build: F) -> Result<Response>
396 where
397 F: Fn() -> reqwest::RequestBuilder,
398 {
399 #[cfg(feature = "retry")]
400 if let Some(policy) = &self.config.retry_policy {
401 return self.send_api_with_retry(build, policy).await;
402 }
403
404 Ok(build().send().await?)
405 }
406
407 #[cfg(feature = "retry")]
408 async fn send_api_with_retry<F>(
409 &self,
410 build: F,
411 policy: &crate::RetryPolicy,
412 ) -> Result<Response>
413 where
414 F: Fn() -> reqwest::RequestBuilder,
415 {
416 let idempotent = build()
417 .build()
418 .map(|request| Self::is_idempotent_method(request.method()))
419 .unwrap_or(false);
420 let attempts = if idempotent {
421 policy.max_attempts_value().max(1)
422 } else {
423 1
424 };
425 let mut delay = policy.initial_delay_value();
426
427 for attempt in 1..=attempts {
428 match build().send().await {
429 Ok(response) => {
430 if attempt == attempts || !Self::is_retryable_status(response.status().as_u16())
431 {
432 return Ok(response);
433 }
434
435 let retry_after = policy.respect_retry_after_value().then(|| {
436 response
437 .headers()
438 .get("retry-after")
439 .and_then(|value| value.to_str().ok())
440 .and_then(parse_retry_after)
441 });
442 let sleep_for = retry_after
443 .flatten()
444 .map(|duration| duration.min(policy.max_delay_value()))
445 .unwrap_or(delay);
446 tokio::time::sleep(sleep_for).await;
447 }
448 Err(error) => {
449 if attempt == attempts || !Self::is_retryable_error(&error) {
450 return Err(Error::Http(error));
451 }
452
453 tokio::time::sleep(delay).await;
454 }
455 }
456
457 delay = next_retry_delay(delay, policy);
458 }
459
460 unreachable!("retry loop always returns on the final attempt")
461 }
462
463 #[cfg(feature = "retry")]
464 fn is_retryable_status(status: u16) -> bool {
465 matches!(status, 429 | 500 | 502 | 503 | 504)
466 }
467
468 #[cfg(feature = "retry")]
469 fn is_idempotent_method(method: &Method) -> bool {
470 matches!(
471 *method,
472 Method::GET
473 | Method::HEAD
474 | Method::OPTIONS
475 | Method::TRACE
476 | Method::PUT
477 | Method::DELETE
478 )
479 }
480
481 #[cfg(feature = "retry")]
482 fn is_retryable_error(error: &reqwest::Error) -> bool {
483 error.is_connect() || error.is_timeout()
484 }
485
486 async fn decode_response<T>(response: Response) -> Result<ApiResponse<T>>
487 where
488 T: DeserializeOwned,
489 {
490 let rate_limit = Self::rate_limit_from_headers(response.headers());
491 let response = Self::ensure_success(response).await?;
492 let envelope = response.json::<DataEnvelope<T>>().await?;
493 Ok(ApiResponse {
494 data: envelope.data,
495 links: envelope.links,
496 meta: envelope.meta,
497 rate_limit,
498 })
499 }
500
501 fn into_page<T>(response: ApiResponse<Vec<T>>) -> Page<T> {
502 Page {
503 data: response.data,
504 links: response.links,
505 meta: response.meta,
506 rate_limit: response.rate_limit,
507 }
508 }
509
510 async fn decode_redirect_location(response: Response) -> Result<Url> {
511 let response_url = response.url().clone();
512 if response.status().is_redirection() {
513 let location = response
514 .headers()
515 .get(LOCATION)
516 .ok_or(Error::MissingRedirectLocation)?;
517 let location = location
518 .to_str()
519 .map_err(|_| Error::MissingRedirectLocation)?;
520 return response_url.join(location).map_err(Error::Url);
521 }
522
523 let _ = Self::ensure_success(response).await?;
524 Err(Error::MissingRedirectLocation)
525 }
526
527 async fn ensure_success(response: Response) -> Result<Response> {
528 if response.status().is_success() {
529 return Ok(response);
530 }
531
532 let status = response.status().as_u16();
533 let rate_limit = Self::rate_limit_from_headers(response.headers());
534 let body = response.text().await.unwrap_or_default();
535 let parsed = serde_json::from_str::<ApiErrorBody>(&body).ok();
536 let message = parsed
537 .as_ref()
538 .and_then(|body| body.message.clone())
539 .filter(|message| !message.is_empty())
540 .unwrap_or_else(|| "request failed".to_string());
541 let code = parsed.as_ref().and_then(|body| body.code.clone());
542 let errors = parsed.and_then(|body| body.errors);
543
544 Err(Error::Api {
545 status,
546 message,
547 code,
548 errors: errors.map(Box::new),
549 rate_limit: rate_limit.map(Box::new),
550 })
551 }
552
553 fn rate_limit_from_headers(headers: &HeaderMap) -> Option<RateLimit> {
554 let rate_limit = RateLimit {
555 limit: header_u64(headers, "x-ratelimit-limit"),
556 remaining: header_u64(headers, "x-ratelimit-remaining"),
557 reset: header_u64(headers, "x-ratelimit-reset"),
558 retry_after: header_u64(headers, "retry-after"),
559 };
560
561 if rate_limit.limit.is_some()
562 || rate_limit.remaining.is_some()
563 || rate_limit.reset.is_some()
564 || rate_limit.retry_after.is_some()
565 {
566 Some(rate_limit)
567 } else {
568 None
569 }
570 }
571}
572
573fn header_u64(headers: &HeaderMap, name: &'static str) -> Option<u64> {
574 headers.get(name)?.to_str().ok()?.parse().ok()
575}
576
577fn api_url(base: &Url, path: &str) -> Result<Url> {
578 validate_api_path(path)?;
579 base.join(path).map_err(Error::Url)
580}
581
582fn validate_api_path(path: &str) -> Result<()> {
584 let valid = !path.is_empty()
585 && path.split('/').all(|segment| {
586 !segment.is_empty()
587 && segment
588 .bytes()
589 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
590 });
591
592 if valid {
593 Ok(())
594 } else {
595 Err(Error::InvalidApiPath)
596 }
597}
598
599fn resource_id(id: &str) -> Result<&str> {
601 let valid = !id.is_empty()
602 && id
603 .bytes()
604 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
605
606 if valid {
607 Ok(id)
608 } else {
609 Err(Error::InvalidResourceId)
610 }
611}
612
613#[cfg(feature = "retry")]
614fn next_retry_delay(current: Duration, policy: &crate::RetryPolicy) -> Duration {
615 let max = policy.max_delay_value();
616 let scaled = current.as_secs_f64() * policy.backoff_factor_value();
617 Duration::try_from_secs_f64(scaled).unwrap_or(max).min(max)
618}
619
620#[cfg(feature = "retry")]
621fn parse_retry_after(value: &str) -> Option<Duration> {
622 let value = value.trim();
623 if let Ok(seconds) = value.parse::<u64>() {
624 return Some(Duration::from_secs(seconds));
625 }
626 let target = httpdate::parse_http_date(value).ok()?;
627 Some(
628 target
629 .duration_since(SystemTime::now())
630 .unwrap_or(Duration::ZERO),
631 )
632}
633
634#[derive(Clone, Debug)]
636pub struct JobsResource {
637 client: CloudConvertClient,
638}
639
640impl JobsResource {
641 pub async fn list(&self, query: &JobListQuery) -> Result<Vec<Job>> {
642 Ok(self.list_page(query).await?.data)
643 }
644
645 pub async fn list_page(&self, query: &JobListQuery) -> Result<Page<Job>> {
646 let response = self
647 .client
648 .get_with_query_response(&self.client.config.api_base_url, "jobs", query)
649 .await?;
650 Ok(CloudConvertClient::into_page(response))
651 }
652
653 pub async fn create(&self, request: impl Into<JobCreateRequest>) -> Result<Job> {
655 Ok(self.create_response(request).await?.data)
656 }
657
658 pub async fn create_response(
659 &self,
660 request: impl Into<JobCreateRequest>,
661 ) -> Result<ApiResponse<Job>> {
662 self.client
663 .post_response(&self.client.config.api_base_url, "jobs", &request.into())
664 .await
665 }
666
667 pub async fn create_and_wait(&self, request: impl Into<JobCreateRequest>) -> Result<Job> {
669 Ok(self.create_and_wait_response(request).await?.data)
670 }
671
672 #[cfg(feature = "socket")]
676 pub async fn create_and_wait_socket(
677 &self,
678 request: impl Into<JobCreateRequest>,
679 ) -> Result<Job> {
680 let job = self.create(request).await?;
681 if job.is_terminal() {
682 return Ok(job);
683 }
684
685 self.wait_socket(&job.id).await
686 }
687
688 pub async fn create_and_wait_response(
689 &self,
690 request: impl Into<JobCreateRequest>,
691 ) -> Result<ApiResponse<Job>> {
692 self.client
693 .post_response(&self.client.config.sync_base_url, "jobs", &request.into())
694 .await
695 }
696
697 pub async fn create_and_wait_redirect_url(
698 &self,
699 request: impl Into<JobCreateRequest>,
700 ) -> Result<Url> {
701 let mut request = request.into();
702 request.redirect = Some(true);
703 self.client
704 .post_redirect_location(&self.client.config.sync_base_url, "jobs", &request)
705 .await
706 }
707
708 pub async fn get(&self, id: impl AsRef<str>) -> Result<Job> {
709 Ok(self.get_response(id).await?.data)
710 }
711
712 pub async fn get_response(&self, id: impl AsRef<str>) -> Result<ApiResponse<Job>> {
713 let id = resource_id(id.as_ref())?;
714 self.client
715 .get_response(&self.client.config.api_base_url, &format!("jobs/{id}"))
716 .await
717 }
718
719 pub async fn get_with_query(&self, id: impl AsRef<str>, query: &JobGetQuery) -> Result<Job> {
720 Ok(self.get_with_query_response(id, query).await?.data)
721 }
722
723 pub async fn get_with_query_response(
724 &self,
725 id: impl AsRef<str>,
726 query: &JobGetQuery,
727 ) -> Result<ApiResponse<Job>> {
728 let id = resource_id(id.as_ref())?;
729 self.client
730 .get_with_query_response(
731 &self.client.config.api_base_url,
732 &format!("jobs/{id}"),
733 query,
734 )
735 .await
736 }
737
738 pub async fn get_redirect_url(&self, id: impl AsRef<str>) -> Result<Url> {
739 let id = resource_id(id.as_ref())?;
740 let query = JobGetQuery::default().redirect(true);
741 self.client
742 .get_redirect_location(
743 &self.client.config.api_base_url,
744 &format!("jobs/{id}"),
745 &query,
746 )
747 .await
748 }
749
750 pub async fn wait(&self, id: impl AsRef<str>) -> Result<Job> {
752 Ok(self.wait_response(id).await?.data)
753 }
754
755 #[cfg(feature = "socket")]
759 pub async fn wait_socket(&self, id: impl AsRef<str>) -> Result<Job> {
760 let id = resource_id(id.as_ref())?.to_string();
761 let mut socket = self.client.socket([SocketChannel::job(id.clone())]).await?;
762 let current = self.get(&id).await?;
763 if current.is_terminal() {
764 let _ = socket.disconnect().await;
765 return Ok(current);
766 }
767
768 loop {
769 let event = match socket.next_event().await {
770 Some(event) => event,
771 None => {
772 let current = self.get(&id).await?;
773 if current.is_terminal() {
774 return Ok(current);
775 }
776 return Err(Error::Socket(format!(
777 "socket closed before job {id} completed"
778 )));
779 }
780 };
781
782 if !event.is_job_event() || !event.is_terminal() {
783 continue;
784 }
785
786 let job = match event.job()? {
787 Some(job) if job.id == id && job.is_terminal() => job,
788 Some(job) if job.id == id => self.get(&id).await?,
789 Some(_) => continue,
790 None => self.get(&id).await?,
791 };
792 if !job.is_terminal() {
793 continue;
794 }
795
796 let _ = socket.disconnect().await;
797 return Ok(job);
798 }
799 }
800
801 #[cfg(feature = "socket")]
802 pub async fn task_events_socket(&self, id: impl AsRef<str>) -> Result<CloudConvertSocket> {
803 self.task_events_socket_with_buffer(id, 64).await
804 }
805
806 #[cfg(feature = "socket")]
807 pub async fn task_events_socket_with_buffer(
808 &self,
809 id: impl AsRef<str>,
810 buffer: usize,
811 ) -> Result<CloudConvertSocket> {
812 let id = resource_id(id.as_ref())?.to_string();
813 self.client
814 .socket_with_buffer([SocketChannel::job_tasks(id)], buffer)
815 .await
816 }
817
818 pub async fn wait_response(&self, id: impl AsRef<str>) -> Result<ApiResponse<Job>> {
819 let id = resource_id(id.as_ref())?;
820 self.client
821 .get_response(&self.client.config.sync_base_url, &format!("jobs/{id}"))
822 .await
823 }
824
825 pub async fn wait_with_query(&self, id: impl AsRef<str>, query: &JobGetQuery) -> Result<Job> {
826 Ok(self.wait_with_query_response(id, query).await?.data)
827 }
828
829 pub async fn wait_with_query_response(
830 &self,
831 id: impl AsRef<str>,
832 query: &JobGetQuery,
833 ) -> Result<ApiResponse<Job>> {
834 let id = resource_id(id.as_ref())?;
835 self.client
836 .get_with_query_response(
837 &self.client.config.sync_base_url,
838 &format!("jobs/{id}"),
839 query,
840 )
841 .await
842 }
843
844 pub async fn wait_redirect_url(&self, id: impl AsRef<str>) -> Result<Url> {
845 let id = resource_id(id.as_ref())?;
846 let query = JobGetQuery::default().redirect(true);
847 self.client
848 .get_redirect_location(
849 &self.client.config.sync_base_url,
850 &format!("jobs/{id}"),
851 &query,
852 )
853 .await
854 }
855
856 pub async fn delete(&self, id: impl AsRef<str>) -> Result<()> {
857 let id = resource_id(id.as_ref())?;
858 self.client
859 .delete(&self.client.config.api_base_url, &format!("jobs/{id}"))
860 .await
861 }
862}
863
864#[derive(Clone, Debug)]
866pub struct TasksResource {
867 client: CloudConvertClient,
868}
869
870impl TasksResource {
871 pub async fn list(&self, query: &TaskListQuery) -> Result<Vec<Task>> {
872 Ok(self.list_page(query).await?.data)
873 }
874
875 pub async fn list_page(&self, query: &TaskListQuery) -> Result<Page<Task>> {
876 let response = self
877 .client
878 .get_with_query_response(&self.client.config.api_base_url, "tasks", query)
879 .await?;
880 Ok(CloudConvertClient::into_page(response))
881 }
882
883 pub async fn create(&self, request: impl Into<TaskRequest>) -> Result<Task> {
884 Ok(self.create_response(request).await?.data)
885 }
886
887 pub async fn create_response(
888 &self,
889 request: impl Into<TaskRequest>,
890 ) -> Result<ApiResponse<Task>> {
891 let request = request.into();
892 let path = request.operation().to_string();
893 self.client
894 .post_response(&self.client.config.api_base_url, &path, &request)
895 .await
896 }
897
898 pub async fn get(&self, id: impl AsRef<str>) -> Result<Task> {
899 Ok(self.get_response(id).await?.data)
900 }
901
902 pub async fn get_response(&self, id: impl AsRef<str>) -> Result<ApiResponse<Task>> {
903 let id = resource_id(id.as_ref())?;
904 self.client
905 .get_response(&self.client.config.api_base_url, &format!("tasks/{id}"))
906 .await
907 }
908
909 pub async fn get_with_query(&self, id: impl AsRef<str>, query: &TaskGetQuery) -> Result<Task> {
910 Ok(self.get_with_query_response(id, query).await?.data)
911 }
912
913 pub async fn get_with_query_response(
914 &self,
915 id: impl AsRef<str>,
916 query: &TaskGetQuery,
917 ) -> Result<ApiResponse<Task>> {
918 let id = resource_id(id.as_ref())?;
919 self.client
920 .get_with_query_response(
921 &self.client.config.api_base_url,
922 &format!("tasks/{id}"),
923 query,
924 )
925 .await
926 }
927
928 pub async fn wait(&self, id: impl AsRef<str>) -> Result<Task> {
930 Ok(self.wait_response(id).await?.data)
931 }
932
933 #[cfg(feature = "socket")]
937 pub async fn wait_socket(&self, id: impl AsRef<str>) -> Result<Task> {
938 let id = resource_id(id.as_ref())?.to_string();
939 let mut socket = self
940 .client
941 .socket([SocketChannel::task(id.clone())])
942 .await?;
943 let current = self.get(&id).await?;
944 if current.is_terminal() {
945 let _ = socket.disconnect().await;
946 return Ok(current);
947 }
948
949 loop {
950 let event = match socket.next_event().await {
951 Some(event) => event,
952 None => {
953 let current = self.get(&id).await?;
954 if current.is_terminal() {
955 return Ok(current);
956 }
957 return Err(Error::Socket(format!(
958 "socket closed before task {id} completed"
959 )));
960 }
961 };
962
963 if !event.is_task_event() || !event.is_terminal() {
964 continue;
965 }
966
967 let task = match event.task()? {
968 Some(task) if task.id == id && task.is_terminal() => task,
969 Some(task) if task.id == id => self.get(&id).await?,
970 Some(_) => continue,
971 None => self.get(&id).await?,
972 };
973 if !task.is_terminal() {
974 continue;
975 }
976
977 let _ = socket.disconnect().await;
978 return Ok(task);
979 }
980 }
981
982 pub async fn wait_response(&self, id: impl AsRef<str>) -> Result<ApiResponse<Task>> {
983 let id = resource_id(id.as_ref())?;
984 self.client
985 .get_response(&self.client.config.sync_base_url, &format!("tasks/{id}"))
986 .await
987 }
988
989 pub async fn wait_with_query(&self, id: impl AsRef<str>, query: &TaskGetQuery) -> Result<Task> {
990 Ok(self.wait_with_query_response(id, query).await?.data)
991 }
992
993 pub async fn wait_with_query_response(
994 &self,
995 id: impl AsRef<str>,
996 query: &TaskGetQuery,
997 ) -> Result<ApiResponse<Task>> {
998 let id = resource_id(id.as_ref())?;
999 self.client
1000 .get_with_query_response(
1001 &self.client.config.sync_base_url,
1002 &format!("tasks/{id}"),
1003 query,
1004 )
1005 .await
1006 }
1007
1008 pub async fn cancel(&self, id: impl AsRef<str>) -> Result<Task> {
1009 Ok(self.cancel_response(id).await?.data)
1010 }
1011
1012 pub async fn cancel_response(&self, id: impl AsRef<str>) -> Result<ApiResponse<Task>> {
1013 let id = resource_id(id.as_ref())?;
1014 let empty = serde_json::json!({});
1015 self.client
1016 .post_response(
1017 &self.client.config.api_base_url,
1018 &format!("tasks/{id}/cancel"),
1019 &empty,
1020 )
1021 .await
1022 }
1023
1024 pub async fn retry(&self, id: impl AsRef<str>) -> Result<Task> {
1025 Ok(self.retry_response(id).await?.data)
1026 }
1027
1028 pub async fn retry_response(&self, id: impl AsRef<str>) -> Result<ApiResponse<Task>> {
1029 let id = resource_id(id.as_ref())?;
1030 let empty = serde_json::json!({});
1031 self.client
1032 .post_response(
1033 &self.client.config.api_base_url,
1034 &format!("tasks/{id}/retry"),
1035 &empty,
1036 )
1037 .await
1038 }
1039
1040 pub async fn delete(&self, id: impl AsRef<str>) -> Result<()> {
1041 let id = resource_id(id.as_ref())?;
1042 self.client
1043 .delete(&self.client.config.api_base_url, &format!("tasks/{id}"))
1044 .await
1045 }
1046}
1047
1048#[derive(Clone, Debug)]
1050pub struct UsersResource {
1051 client: CloudConvertClient,
1052}
1053
1054impl UsersResource {
1055 pub async fn me(&self) -> Result<User> {
1056 Ok(self.me_response().await?.data)
1057 }
1058
1059 pub async fn me_response(&self) -> Result<ApiResponse<User>> {
1060 self.client
1061 .get_response(&self.client.config.api_base_url, "users/me")
1062 .await
1063 }
1064
1065 #[cfg(feature = "socket")]
1066 pub async fn job_events_socket(&self) -> Result<CloudConvertSocket> {
1067 self.job_events_socket_with_buffer(64).await
1068 }
1069
1070 #[cfg(feature = "socket")]
1071 pub async fn job_events_socket_with_buffer(&self, buffer: usize) -> Result<CloudConvertSocket> {
1072 let user = self.me().await?;
1073 self.client
1074 .socket_with_buffer([SocketChannel::user_jobs(user.id)], buffer)
1075 .await
1076 }
1077
1078 #[cfg(feature = "socket")]
1079 pub async fn task_events_socket(&self) -> Result<CloudConvertSocket> {
1080 self.task_events_socket_with_buffer(64).await
1081 }
1082
1083 #[cfg(feature = "socket")]
1084 pub async fn task_events_socket_with_buffer(
1085 &self,
1086 buffer: usize,
1087 ) -> Result<CloudConvertSocket> {
1088 let user = self.me().await?;
1089 self.client
1090 .socket_with_buffer([SocketChannel::user_tasks(user.id)], buffer)
1091 .await
1092 }
1093
1094 #[cfg(feature = "socket")]
1095 pub async fn events_socket(&self) -> Result<CloudConvertSocket> {
1096 self.events_socket_with_buffer(64).await
1097 }
1098
1099 #[cfg(feature = "socket")]
1100 pub async fn events_socket_with_buffer(&self, buffer: usize) -> Result<CloudConvertSocket> {
1101 let user = self.me().await?;
1102 self.client
1103 .socket_with_buffer(
1104 [
1105 SocketChannel::user_jobs(user.id.clone()),
1106 SocketChannel::user_tasks(user.id),
1107 ],
1108 buffer,
1109 )
1110 .await
1111 }
1112}
1113
1114#[derive(Clone, Debug)]
1116pub struct WebhooksResource {
1117 client: CloudConvertClient,
1118}
1119
1120impl WebhooksResource {
1121 pub async fn create(&self, request: &WebhookCreateRequest) -> Result<Webhook> {
1122 Ok(self.create_response(request).await?.data)
1123 }
1124
1125 pub async fn create_response(
1126 &self,
1127 request: &WebhookCreateRequest,
1128 ) -> Result<ApiResponse<Webhook>> {
1129 self.client
1130 .post_response(&self.client.config.api_base_url, "webhooks", request)
1131 .await
1132 }
1133
1134 pub async fn list(&self, query: &WebhookListQuery) -> Result<Vec<Webhook>> {
1135 Ok(self.list_page(query).await?.data)
1136 }
1137
1138 pub async fn list_page(&self, query: &WebhookListQuery) -> Result<Page<Webhook>> {
1139 let response = self
1140 .client
1141 .get_with_query_response(&self.client.config.api_base_url, "users/me/webhooks", query)
1142 .await?;
1143 Ok(CloudConvertClient::into_page(response))
1144 }
1145
1146 pub async fn delete(&self, id: impl AsRef<str>) -> Result<()> {
1147 let id = resource_id(id.as_ref())?;
1148 self.client
1149 .delete(&self.client.config.api_base_url, &format!("webhooks/{id}"))
1150 .await
1151 }
1152}
1153
1154#[derive(Clone, Debug)]
1156pub struct OperationsResource {
1157 client: CloudConvertClient,
1158}
1159
1160impl OperationsResource {
1161 pub async fn list(&self, query: &OperationListQuery) -> Result<Vec<Operation>> {
1162 Ok(self.list_page(query).await?.data)
1163 }
1164
1165 pub async fn list_page(&self, query: &OperationListQuery) -> Result<Page<Operation>> {
1166 let response = self
1167 .client
1168 .get_with_query_response(&self.client.config.api_base_url, "operations", query)
1169 .await?;
1170 Ok(CloudConvertClient::into_page(response))
1171 }
1172}
1173
1174fn form_value(value: &Value) -> String {
1175 match value {
1176 Value::String(value) => value.clone(),
1177 Value::Number(value) => value.to_string(),
1178 Value::Bool(value) => value.to_string(),
1179 Value::Null => String::new(),
1180 other => other.to_string(),
1181 }
1182}
1183
1184fn temporary_download_path(destination: &Path) -> PathBuf {
1185 let mut name = OsString::from(".");
1186 name.push(
1187 destination
1188 .file_name()
1189 .unwrap_or_else(|| OsStr::new("download")),
1190 );
1191 name.push(format!(".cloudconvert-{}.tmp", unique_suffix()));
1192
1193 match destination.parent() {
1194 Some(parent) => parent.join(name),
1195 None => PathBuf::from(name),
1196 }
1197}
1198
1199struct TemporaryDownload {
1200 path: PathBuf,
1201 committed: bool,
1202}
1203
1204impl TemporaryDownload {
1205 fn new(path: PathBuf) -> Self {
1206 Self {
1207 path,
1208 committed: false,
1209 }
1210 }
1211
1212 fn path(&self) -> &Path {
1213 &self.path
1214 }
1215
1216 fn commit(&mut self) {
1217 self.committed = true;
1218 }
1219}
1220
1221impl Drop for TemporaryDownload {
1222 fn drop(&mut self) {
1223 if !self.committed {
1224 let _ = std::fs::remove_file(&self.path);
1225 }
1226 }
1227}
1228
1229fn unique_suffix() -> String {
1230 let nanos = SystemTime::now()
1231 .duration_since(UNIX_EPOCH)
1232 .map(|duration| duration.as_nanos())
1233 .unwrap_or_default();
1234
1235 format!("{}-{nanos}", std::process::id())
1236}
1237
1238#[cfg(all(test, feature = "retry"))]
1239mod retry_after_tests {
1240 use super::parse_retry_after;
1241 use std::time::{Duration, SystemTime};
1242
1243 #[test]
1244 fn parses_delta_seconds() {
1245 assert_eq!(parse_retry_after("30"), Some(Duration::from_secs(30)));
1246 assert_eq!(parse_retry_after(" 5 "), Some(Duration::from_secs(5)));
1247 }
1248
1249 #[test]
1250 fn parses_http_date_in_the_future() {
1251 let target = SystemTime::now() + Duration::from_secs(120);
1252 let header = httpdate::fmt_http_date(target);
1253 let delay = parse_retry_after(&header).expect("http-date should parse");
1254 assert!(delay <= Duration::from_secs(121));
1255 assert!(delay >= Duration::from_secs(115));
1256 }
1257
1258 #[test]
1259 fn http_date_in_the_past_yields_zero() {
1260 let target = SystemTime::now() - Duration::from_secs(120);
1261 let header = httpdate::fmt_http_date(target);
1262 assert_eq!(parse_retry_after(&header), Some(Duration::ZERO));
1263 }
1264
1265 #[test]
1266 fn rejects_unparseable_value() {
1267 assert_eq!(parse_retry_after("not-a-date"), None);
1268 }
1269
1270 #[test]
1271 fn backoff_factor_rejects_non_finite_values() {
1272 use crate::RetryPolicy;
1273 assert_eq!(
1274 RetryPolicy::default()
1275 .backoff_factor(f64::INFINITY)
1276 .backoff_factor_value(),
1277 1.0
1278 );
1279 assert_eq!(
1280 RetryPolicy::default()
1281 .backoff_factor(f64::NAN)
1282 .backoff_factor_value(),
1283 1.0
1284 );
1285 assert_eq!(
1286 RetryPolicy::default()
1287 .backoff_factor(0.25)
1288 .backoff_factor_value(),
1289 1.0
1290 );
1291 assert_eq!(
1292 RetryPolicy::default()
1293 .backoff_factor(3.0)
1294 .backoff_factor_value(),
1295 3.0
1296 );
1297 }
1298
1299 #[test]
1300 fn next_retry_delay_saturates_instead_of_panicking() {
1301 use super::next_retry_delay;
1302 use crate::RetryPolicy;
1303
1304 let policy = RetryPolicy::default()
1305 .max_delay(Duration::from_secs(10))
1306 .backoff_factor(1e308);
1307 let delay = next_retry_delay(Duration::from_secs(u64::MAX / 2), &policy);
1308 assert_eq!(delay, Duration::from_secs(10));
1309
1310 let policy = RetryPolicy::default()
1311 .max_delay(Duration::from_secs(10))
1312 .backoff_factor(2.0);
1313 let delay = next_retry_delay(Duration::from_secs(1), &policy);
1314 assert_eq!(delay, Duration::from_secs(2));
1315 }
1316}