Skip to main content

cloudconvert_sdk/
socket.rs

1//! CloudConvert Socket.IO channel names, subscriptions, and event decoding.
2//!
3//! Channel helpers build the `private-*` names CloudConvert expects. With the
4//! `socket` feature enabled, [`CloudConvertSocket`] manages subscriptions and
5//! exposes typed job and task lifecycle events for wait helpers in
6//! [`crate::JobsResource`] and [`crate::TasksResource`].
7
8use std::{borrow::Cow, collections::BTreeMap, fmt};
9
10#[cfg(feature = "socket")]
11use futures_util::{FutureExt, future::BoxFuture};
12#[cfg(feature = "socket")]
13use rust_socketio::{
14    Event, Payload,
15    asynchronous::{Client as SocketIoClient, ClientBuilder as SocketIoClientBuilder},
16};
17use serde::Serialize;
18#[cfg(feature = "socket")]
19use serde::de::DeserializeOwned;
20#[cfg(feature = "socket")]
21use serde_json::Value;
22#[cfg(feature = "socket")]
23use tokio::sync::mpsc;
24
25#[cfg(feature = "socket")]
26use crate::{
27    Error, Result,
28    jobs::{Job, Task},
29};
30
31/// Socket.IO channel selector for job, task, or user-scoped event streams.
32#[derive(Clone, Debug, Eq, PartialEq)]
33#[non_exhaustive]
34pub enum SocketChannel {
35    Job { job_id: String },
36    JobTasks { job_id: String },
37    Task { task_id: String },
38    UserJobs { user_id: String },
39    UserTasks { user_id: String },
40    Custom(String),
41}
42
43impl SocketChannel {
44    pub fn custom(channel: impl Into<String>) -> Self {
45        Self::Custom(channel.into())
46    }
47
48    pub fn job(job_id: impl Into<String>) -> Self {
49        Self::Job {
50            job_id: job_id.into(),
51        }
52    }
53
54    pub fn job_tasks(job_id: impl Into<String>) -> Self {
55        Self::JobTasks {
56            job_id: job_id.into(),
57        }
58    }
59
60    pub fn task(task_id: impl Into<String>) -> Self {
61        Self::Task {
62            task_id: task_id.into(),
63        }
64    }
65
66    pub fn user_jobs(user_id: impl Into<String>) -> Self {
67        Self::UserJobs {
68            user_id: user_id.into(),
69        }
70    }
71
72    pub fn user_tasks(user_id: impl Into<String>) -> Self {
73        Self::UserTasks {
74            user_id: user_id.into(),
75        }
76    }
77
78    /// Returns the `private-*` channel name CloudConvert expects at subscribe time.
79    pub fn name(&self) -> Cow<'_, str> {
80        match self {
81            Self::Job { job_id } => Cow::Owned(format!("private-job.{job_id}")),
82            Self::JobTasks { job_id } => Cow::Owned(format!("private-job.{job_id}.tasks")),
83            Self::Task { task_id } => Cow::Owned(format!("private-task.{task_id}")),
84            Self::UserJobs { user_id } => Cow::Owned(format!("private-user.{user_id}.jobs")),
85            Self::UserTasks { user_id } => Cow::Owned(format!("private-user.{user_id}.tasks")),
86            Self::Custom(channel) => Cow::Borrowed(channel.as_str()),
87        }
88    }
89
90    pub fn is_job(&self) -> bool {
91        matches!(self, Self::Job { .. })
92    }
93
94    pub fn is_job_tasks(&self) -> bool {
95        matches!(self, Self::JobTasks { .. })
96    }
97
98    pub fn is_task(&self) -> bool {
99        matches!(self, Self::Task { .. })
100    }
101
102    pub fn is_user_jobs(&self) -> bool {
103        matches!(self, Self::UserJobs { .. })
104    }
105
106    pub fn is_user_tasks(&self) -> bool {
107        matches!(self, Self::UserTasks { .. })
108    }
109
110    pub fn job_id(&self) -> Option<&str> {
111        match self {
112            Self::Job { job_id } | Self::JobTasks { job_id } => Some(job_id),
113            _ => None,
114        }
115    }
116
117    pub fn task_id(&self) -> Option<&str> {
118        match self {
119            Self::Task { task_id } => Some(task_id),
120            _ => None,
121        }
122    }
123
124    pub fn user_id(&self) -> Option<&str> {
125        match self {
126            Self::UserJobs { user_id } | Self::UserTasks { user_id } => Some(user_id),
127            _ => None,
128        }
129    }
130}
131
132/// Job lifecycle event names emitted over Socket.IO.
133#[derive(Clone, Copy, Debug, Eq, PartialEq)]
134#[non_exhaustive]
135pub enum JobSocketEvent {
136    Created,
137    Updated,
138    Finished,
139    Failed,
140}
141
142impl JobSocketEvent {
143    pub fn name(self) -> &'static str {
144        match self {
145            Self::Created => "job.created",
146            Self::Updated => "job.updated",
147            Self::Finished => "job.finished",
148            Self::Failed => "job.failed",
149        }
150    }
151
152    pub fn from_name(name: &str) -> Option<Self> {
153        Some(match name {
154            "job.created" => Self::Created,
155            "job.updated" => Self::Updated,
156            "job.finished" => Self::Finished,
157            "job.failed" => Self::Failed,
158            _ => return None,
159        })
160    }
161}
162
163/// Task lifecycle event names emitted over Socket.IO.
164#[derive(Clone, Copy, Debug, Eq, PartialEq)]
165#[non_exhaustive]
166pub enum TaskSocketEvent {
167    Created,
168    Updated,
169    Finished,
170    Failed,
171}
172
173impl TaskSocketEvent {
174    pub fn name(self) -> &'static str {
175        match self {
176            Self::Created => "task.created",
177            Self::Updated => "task.updated",
178            Self::Finished => "task.finished",
179            Self::Failed => "task.failed",
180        }
181    }
182
183    pub fn from_name(name: &str) -> Option<Self> {
184        Some(match name {
185            "task.created" => Self::Created,
186            "task.updated" => Self::Updated,
187            "task.finished" => Self::Finished,
188            "task.failed" => Self::Failed,
189            _ => return None,
190        })
191    }
192}
193
194/// Parsed Socket.IO event name, including unknown custom events.
195#[derive(Clone, Debug, Eq, PartialEq)]
196#[non_exhaustive]
197pub enum SocketEventKind {
198    Job(JobSocketEvent),
199    Task(TaskSocketEvent),
200    Other(String),
201}
202
203impl SocketEventKind {
204    pub fn from_name(name: impl Into<String>) -> Self {
205        let name = name.into();
206        if let Some(event) = JobSocketEvent::from_name(&name) {
207            return Self::Job(event);
208        }
209        if let Some(event) = TaskSocketEvent::from_name(&name) {
210            return Self::Task(event);
211        }
212        Self::Other(name)
213    }
214
215    pub fn name(&self) -> &str {
216        match self {
217            Self::Job(event) => event.name(),
218            Self::Task(event) => event.name(),
219            Self::Other(event) => event.as_str(),
220        }
221    }
222
223    pub fn is_job(&self) -> bool {
224        matches!(self, Self::Job(_))
225    }
226
227    pub fn is_task(&self) -> bool {
228        matches!(self, Self::Task(_))
229    }
230}
231
232/// Bearer-authenticated subscribe payload sent to the Socket.IO server.
233#[derive(Clone, Serialize)]
234pub struct SocketSubscription {
235    channel: String,
236    auth: SocketAuth,
237}
238
239impl SocketSubscription {
240    pub(crate) fn new(channel: impl Into<String>, api_key: &str) -> Self {
241        Self {
242            channel: channel.into(),
243            auth: SocketAuth::bearer(api_key),
244        }
245    }
246
247    pub fn channel(&self) -> &str {
248        self.channel.as_str()
249    }
250}
251
252impl fmt::Debug for SocketSubscription {
253    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
254        f.debug_struct("SocketSubscription")
255            .field("channel", &self.channel)
256            .field("auth", &"REDACTED")
257            .finish()
258    }
259}
260
261#[derive(Clone, Serialize)]
262pub struct SocketAuth {
263    headers: BTreeMap<String, String>,
264}
265
266impl SocketAuth {
267    fn bearer(api_key: &str) -> Self {
268        let mut headers = BTreeMap::new();
269        headers.insert("Authorization".to_string(), format!("Bearer {api_key}"));
270        Self { headers }
271    }
272}
273
274impl fmt::Debug for SocketAuth {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        f.write_str("SocketAuth(REDACTED)")
277    }
278}
279
280/// Socket.IO base URL for production or sandbox environments.
281pub fn socket_base_url(sandbox: bool) -> &'static str {
282    if sandbox {
283        "https://socketio.sandbox.cloudconvert.com"
284    } else {
285        "https://socketio.cloudconvert.com"
286    }
287}
288
289/// One decoded Socket.IO event with optional channel metadata and JSON payload.
290#[cfg(feature = "socket")]
291#[derive(Clone, Debug)]
292pub struct SocketEvent {
293    event: String,
294    channel: Option<String>,
295    data: Value,
296}
297
298#[cfg(feature = "socket")]
299impl SocketEvent {
300    pub fn event(&self) -> &str {
301        self.event.as_str()
302    }
303
304    pub fn channel(&self) -> Option<&str> {
305        self.channel.as_deref()
306    }
307
308    pub fn data(&self) -> &Value {
309        &self.data
310    }
311
312    pub fn kind(&self) -> SocketEventKind {
313        SocketEventKind::from_name(self.event.clone())
314    }
315
316    pub fn job_event(&self) -> Option<JobSocketEvent> {
317        JobSocketEvent::from_name(&self.event)
318    }
319
320    pub fn task_event(&self) -> Option<TaskSocketEvent> {
321        TaskSocketEvent::from_name(&self.event)
322    }
323
324    pub fn is_job_event(&self) -> bool {
325        self.event.starts_with("job.")
326    }
327
328    pub fn is_task_event(&self) -> bool {
329        self.event.starts_with("task.")
330    }
331
332    pub fn is_finished(&self) -> bool {
333        self.event.ends_with(".finished")
334    }
335
336    pub fn is_created(&self) -> bool {
337        self.event.ends_with(".created")
338    }
339
340    pub fn is_updated(&self) -> bool {
341        self.event.ends_with(".updated")
342    }
343
344    pub fn is_failed(&self) -> bool {
345        self.event.ends_with(".failed")
346    }
347
348    pub fn is_terminal(&self) -> bool {
349        self.is_finished() || self.is_failed()
350    }
351
352    pub fn job(&self) -> Result<Option<Job>> {
353        decode_socket_data_field(&self.data, "job")
354    }
355
356    pub fn task(&self) -> Result<Option<Task>> {
357        decode_socket_data_field(&self.data, "task")
358    }
359
360    #[allow(deprecated)]
361    fn from_payload(event: impl Into<String>, payload: Payload) -> Self {
362        let (channel, data) = match payload {
363            Payload::Text(values) => split_socket_payload_values(values),
364            Payload::String(value) => {
365                let data = serde_json::from_str(&value).unwrap_or(Value::String(value));
366                (None, data)
367            }
368            Payload::Binary(bytes) => (
369                None,
370                Value::Array(
371                    bytes
372                        .into_iter()
373                        .map(|byte| Value::Number(byte.into()))
374                        .collect(),
375                ),
376            ),
377        };
378
379        Self {
380            event: event.into(),
381            channel,
382            data,
383        }
384    }
385}
386
387/// Managed Socket.IO connection that buffers job and task lifecycle events.
388///
389/// Requires the `socket` crate feature.
390#[cfg(feature = "socket")]
391pub struct CloudConvertSocket {
392    client: SocketIoClient,
393    receiver: mpsc::Receiver<SocketEvent>,
394}
395
396#[cfg(feature = "socket")]
397impl fmt::Debug for CloudConvertSocket {
398    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
399        formatter
400            .debug_struct("CloudConvertSocket")
401            .field("client", &"Socket.IO client")
402            .field("receiver", &"event receiver")
403            .finish()
404    }
405}
406
407#[cfg(feature = "socket")]
408impl CloudConvertSocket {
409    /// Connects to Socket.IO and subscribes to the provided channels.
410    pub async fn connect(
411        base_url: impl Into<String>,
412        subscriptions: impl IntoIterator<Item = SocketSubscription>,
413    ) -> Result<Self> {
414        Self::connect_with_buffer(base_url, subscriptions, 64).await
415    }
416
417    pub async fn connect_with_buffer(
418        base_url: impl Into<String>,
419        subscriptions: impl IntoIterator<Item = SocketSubscription>,
420        buffer: usize,
421    ) -> Result<Self> {
422        let (sender, receiver) = mpsc::channel(buffer.max(1));
423        let subscriptions: Vec<SocketSubscription> = subscriptions.into_iter().collect();
424        let payloads = subscriptions
425            .iter()
426            .map(serde_json::to_value)
427            .collect::<std::result::Result<Vec<Value>, _>>()?;
428        let client = socket_client_builder(base_url.into(), sender, payloads)
429            .connect()
430            .await
431            .map_err(socket_error)?;
432        let socket = Self { client, receiver };
433
434        for subscription in subscriptions {
435            socket.subscribe(subscription).await?;
436        }
437
438        Ok(socket)
439    }
440
441    pub async fn subscribe(&self, subscription: SocketSubscription) -> Result<()> {
442        let payload = serde_json::to_value(subscription)?;
443        self.client
444            .emit("subscribe", payload)
445            .await
446            .map_err(socket_error)
447    }
448
449    /// Awaits the next buffered Socket.IO event, if any.
450    pub async fn next_event(&mut self) -> Option<SocketEvent> {
451        self.receiver.recv().await
452    }
453
454    pub async fn disconnect(&self) -> Result<()> {
455        self.client.disconnect().await.map_err(socket_error)
456    }
457}
458
459#[cfg(feature = "socket")]
460fn socket_client_builder(
461    base_url: String,
462    sender: mpsc::Sender<SocketEvent>,
463    subscribe_payloads: Vec<Value>,
464) -> SocketIoClientBuilder {
465    let mut builder = SocketIoClientBuilder::new(base_url)
466        .reconnect(true)
467        .reconnect_on_disconnect(true);
468
469    for event in [
470        JobSocketEvent::Created.name(),
471        JobSocketEvent::Updated.name(),
472        JobSocketEvent::Finished.name(),
473        JobSocketEvent::Failed.name(),
474        TaskSocketEvent::Created.name(),
475        TaskSocketEvent::Updated.name(),
476        TaskSocketEvent::Finished.name(),
477        TaskSocketEvent::Failed.name(),
478    ] {
479        builder = builder.on(event, socket_event_callback(event, sender.clone()));
480    }
481
482    // CloudConvert drops channel subscriptions on reconnect, so resubscribe on connect.
483    builder.on(Event::Connect, resubscribe_callback(subscribe_payloads))
484}
485
486#[cfg(feature = "socket")]
487fn resubscribe_callback(
488    subscribe_payloads: Vec<Value>,
489) -> impl FnMut(Payload, SocketIoClient) -> BoxFuture<'static, ()> + Send + Sync + 'static {
490    move |_payload, client| {
491        let subscribe_payloads = subscribe_payloads.clone();
492        async move {
493            for payload in subscribe_payloads {
494                let _ = client.emit("subscribe", payload).await;
495            }
496        }
497        .boxed()
498    }
499}
500
501#[cfg(feature = "socket")]
502fn socket_event_callback(
503    event: &'static str,
504    sender: mpsc::Sender<SocketEvent>,
505) -> impl FnMut(Payload, SocketIoClient) -> BoxFuture<'static, ()> + Send + Sync + 'static {
506    move |payload, _client| {
507        let sender = sender.clone();
508        async move {
509            let _ = sender.send(SocketEvent::from_payload(event, payload)).await;
510        }
511        .boxed()
512    }
513}
514
515#[cfg(feature = "socket")]
516fn split_socket_payload_values(mut values: Vec<Value>) -> (Option<String>, Value) {
517    match values.as_mut_slice() {
518        [Value::String(channel), data] => (Some(channel.clone()), data.clone()),
519        [_] => (None, values.remove(0)),
520        _ => (None, Value::Array(values)),
521    }
522}
523
524#[cfg(feature = "socket")]
525fn decode_socket_data_field<T>(data: &Value, field: &'static str) -> Result<Option<T>>
526where
527    T: DeserializeOwned,
528{
529    let Some(value) = data.get(field) else {
530        return Ok(None);
531    };
532
533    serde_json::from_value(value.clone())
534        .map(Some)
535        .map_err(Error::Json)
536}
537
538#[cfg(feature = "socket")]
539fn socket_error(error: impl fmt::Display) -> Error {
540    Error::Socket(error.to_string())
541}
542
543#[cfg(all(test, feature = "socket"))]
544mod managed_socket_tests {
545    use super::*;
546    use std::time::Duration;
547
548    use serde_json::json;
549
550    #[test]
551    fn socket_event_decodes_channel_and_job_payload() {
552        let event = SocketEvent::from_payload(
553            "job.finished",
554            Payload::Text(vec![
555                json!("private-job.job_1"),
556                json!({
557                    "job": {
558                        "id": "job_1",
559                        "status": "finished",
560                        "tasks": []
561                    }
562                }),
563            ]),
564        );
565
566        assert_eq!(event.event(), "job.finished");
567        assert_eq!(event.channel(), Some("private-job.job_1"));
568        assert_eq!(event.kind(), SocketEventKind::Job(JobSocketEvent::Finished));
569        assert_eq!(event.job_event(), Some(JobSocketEvent::Finished));
570        assert!(event.is_job_event());
571        assert!(event.is_finished());
572        assert!(event.is_terminal());
573        assert_eq!(event.job().unwrap().unwrap().id, "job_1");
574    }
575
576    #[test]
577    fn socket_event_preserves_unknown_payload_shapes() {
578        let event = SocketEvent::from_payload(
579            "task.updated",
580            Payload::Text(vec![json!({"unexpected": true})]),
581        );
582
583        assert_eq!(event.channel(), None);
584        assert_eq!(event.data()["unexpected"], true);
585        assert_eq!(event.task_event(), Some(TaskSocketEvent::Updated));
586        assert!(event.is_updated());
587        assert!(event.task().unwrap().is_none());
588    }
589
590    #[test]
591    fn socket_event_decodes_task_payload_and_status_helpers() {
592        let event = SocketEvent::from_payload(
593            "task.failed",
594            Payload::from(
595                json!({
596                    "task": {
597                        "id": "task_1",
598                        "job_id": "job_1",
599                        "operation": "convert",
600                        "status": "error"
601                    }
602                })
603                .to_string(),
604            ),
605        );
606
607        assert_eq!(event.event(), "task.failed");
608        assert_eq!(event.channel(), None);
609        assert_eq!(event.kind(), SocketEventKind::Task(TaskSocketEvent::Failed));
610        assert_eq!(event.task_event(), Some(TaskSocketEvent::Failed));
611        assert_eq!(event.job_event(), None);
612        assert!(event.is_task_event());
613        assert!(!event.is_job_event());
614        assert!(event.is_failed());
615        assert!(event.is_terminal());
616        assert_eq!(event.task().unwrap().unwrap().id, "task_1");
617        assert!(event.job().unwrap().is_none());
618    }
619
620    #[test]
621    fn socket_event_handles_created_updated_binary_and_unknown_payloads() {
622        let created = SocketEvent::from_payload(
623            "task.created",
624            Payload::Text(vec![
625                json!("private-task.task_1"),
626                json!({
627                    "task": {
628                        "id": "task_1",
629                        "job_id": "job_1",
630                        "operation": "convert",
631                        "status": "waiting"
632                    }
633                }),
634            ]),
635        );
636        assert_eq!(created.channel(), Some("private-task.task_1"));
637        assert!(created.is_created());
638        assert!(!created.is_terminal());
639
640        let binary = SocketEvent::from_payload(
641            "job.updated",
642            Payload::Binary(bytes::Bytes::from_static(&[1, 2, 3])),
643        );
644        assert_eq!(binary.data(), &json!([1, 2, 3]));
645        assert!(binary.is_updated());
646
647        let string = SocketEvent::from_payload("job.created", Payload::from("raw".to_string()));
648        assert_eq!(string.data(), &json!("raw"));
649        assert!(string.is_created());
650
651        let unknown = SocketEvent::from_payload(
652            "custom.event",
653            Payload::Text(vec![json!("one"), json!("two"), json!("three")]),
654        );
655        assert_eq!(
656            unknown.kind(),
657            SocketEventKind::Other("custom.event".to_string())
658        );
659        assert_eq!(unknown.kind().name(), "custom.event");
660        assert!(!unknown.kind().is_job());
661        assert!(!unknown.kind().is_task());
662        assert!(!unknown.is_job_event());
663        assert!(!unknown.is_task_event());
664        assert_eq!(unknown.data(), &json!(["one", "two", "three"]));
665    }
666
667    #[test]
668    fn socket_event_reports_json_decode_errors_for_bad_payload_fields() {
669        let event = SocketEvent::from_payload(
670            "job.finished",
671            Payload::Text(vec![json!({
672                "job": "not a job object"
673            })]),
674        );
675
676        assert!(matches!(event.job().unwrap_err(), Error::Json(_)));
677    }
678
679    #[tokio::test]
680    async fn socket_connect_reports_socket_errors_for_unavailable_local_endpoint() {
681        let result = tokio::time::timeout(
682            Duration::from_secs(2),
683            CloudConvertSocket::connect("http://127.0.0.1:1", Vec::<SocketSubscription>::new()),
684        )
685        .await
686        .expect("local refused socket connection should finish quickly");
687
688        assert!(matches!(result, Err(Error::Socket(_))));
689    }
690}