Skip to main content

cloudconvert_sdk/
client.rs

1//! Async HTTP client and resource facades for CloudConvert API v2.
2//!
3//! [`CloudConvertClient`] owns bearer authentication, optional retry policy,
4//! and two HTTP clients: one that follows redirects for ordinary API traffic
5//! and one that preserves `Location` headers for sync redirect endpoints.
6//! Resource types such as [`JobsResource`] group REST calls by domain. File
7//! transfer helpers upload multipart data to import tasks and stream downloads
8//! from export URLs outside the API base URL.
9
10#[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/// Entry point for authenticated CloudConvert API calls and file transfer.
50#[derive(Clone, Debug)]
51pub struct CloudConvertClient {
52    config: Arc<CloudConvertConfig>,
53    http: reqwest::Client,
54    // Preserves redirect Location headers for sync wait and redirect URL endpoints.
55    redirectless_http: reqwest::Client,
56}
57
58impl CloudConvertClient {
59    /// Starts a client builder authenticated with an API key.
60    pub fn builder(api_key: ApiKey) -> ClientBuilder {
61        ClientBuilder::new(api_key)
62    }
63
64    /// Starts a client builder authenticated with an OAuth access token.
65    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    /// Returns the resolved client configuration, including base URLs and region.
82    pub fn config(&self) -> &CloudConvertConfig {
83        &self.config
84    }
85
86    /// Job lifecycle endpoints: create, wait, list, and delete.
87    pub fn jobs(&self) -> JobsResource {
88        JobsResource {
89            client: self.clone(),
90        }
91    }
92
93    /// Standalone task endpoints outside a job graph.
94    pub fn tasks(&self) -> TasksResource {
95        TasksResource {
96            client: self.clone(),
97        }
98    }
99
100    /// Account endpoints and user-scoped Socket.IO subscriptions.
101    pub fn users(&self) -> UsersResource {
102        UsersResource {
103            client: self.clone(),
104        }
105    }
106
107    /// Webhook registration and listing for the authenticated account.
108    pub fn webhooks(&self) -> WebhooksResource {
109        WebhooksResource {
110            client: self.clone(),
111        }
112    }
113
114    /// CloudConvert operations metadata used to discover engines and options.
115    pub fn operations(&self) -> OperationsResource {
116        OperationsResource {
117            client: self.clone(),
118        }
119    }
120
121    /// Socket.IO base URL for the client's sandbox or production environment.
122    pub fn socket_base_url(&self) -> &'static str {
123        crate::socket::socket_base_url(self.config.sandbox())
124    }
125
126    /// Builds a bearer-authenticated subscription payload for a Socket.IO channel.
127    pub fn socket_subscription(&self, channel: SocketChannel) -> SocketSubscription {
128        SocketSubscription::new(channel.name().into_owned(), self.config.credential.expose())
129    }
130
131    /// Connects to Socket.IO and subscribes to the given channels.
132    ///
133    /// Requires the `socket` crate feature.
134    #[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    /// Downloads a URL, typically an export file link, into memory.
158    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    /// Streams a download without buffering the full response in memory.
168    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    /// Streams a download to `destination` through a sibling temporary file.
180    ///
181    /// The destination is replaced atomically on success; the temp file is
182    /// removed if the download or rename fails.
183    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    /// Uploads bytes to an `import/upload` task using its presigned form.
195    ///
196    /// Returns [`Error::UploadTaskNotReady`] when the task has no upload form yet.
197    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
582// Rejects path segments that could escape the configured API base URL.
583fn 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
599// Resource ids are interpolated into URL paths, so only safe tokens are accepted.
600fn 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/// Job REST endpoints, including sync wait and optional Socket.IO wait helpers.
635#[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    /// Creates a job and returns immediately with the current job state.
654    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    /// Creates a job on the sync API host and blocks until it reaches a terminal state.
668    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    /// Creates a job, then waits for completion via Socket.IO when still in flight.
673    ///
674    /// Requires the `socket` crate feature.
675    #[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    /// Blocks on the sync API host until the job reaches a terminal state.
751    pub async fn wait(&self, id: impl AsRef<str>) -> Result<Job> {
752        Ok(self.wait_response(id).await?.data)
753    }
754
755    /// Waits for a terminal job event over Socket.IO, falling back to REST on gaps.
756    ///
757    /// Requires the `socket` crate feature.
758    #[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/// Standalone task REST endpoints and optional Socket.IO wait helpers.
865#[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    /// Blocks on the sync API host until the task reaches a terminal state.
929    pub async fn wait(&self, id: impl AsRef<str>) -> Result<Task> {
930        Ok(self.wait_response(id).await?.data)
931    }
932
933    /// Waits for a terminal task event over Socket.IO, falling back to REST on gaps.
934    ///
935    /// Requires the `socket` crate feature.
936    #[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/// Account endpoints and helpers that resolve user-scoped Socket.IO channels.
1049#[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/// Webhook registration endpoints for the authenticated account.
1115#[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/// Operations metadata listing engines, formats, and documented options.
1155#[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}