1use 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#[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 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#[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#[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#[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#[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
280pub 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#[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#[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 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 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 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}