Skip to main content

videosdk/
common.rs

1//! Types shared across resources.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Deserializer, Serialize};
6
7/// HATEOAS-style links returned on many resources.
8pub type ResourceLinks = HashMap<String, String>;
9
10/// Deserializes an explicit JSON `null` as `T::default()`.
11///
12/// `#[serde(default)]` alone only covers a *missing* key — an explicit `null`
13/// still fails with "invalid type: null". The API returns `null` where Go's
14/// `encoding/json` would have silently produced a zero value, so every
15/// defaulted field pairs this with `default`:
16///
17/// ```ignore
18/// #[serde(default, deserialize_with = "null_to_default")]
19/// pub name: String,
20/// ```
21pub(crate) fn null_to_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
22where
23    D: Deserializer<'de>,
24    T: Deserialize<'de> + Default,
25{
26    Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
27}
28
29/// The plain `{message}` body returned by many action and delete endpoints.
30#[derive(Debug, Clone, Deserialize, Serialize)]
31pub struct MessageResponse {
32    /// The server's confirmation message.
33    pub message: String,
34}
35
36/// Defines an open string enum: a transparent newtype over a string, carrying
37/// associated constants for the values the API documents today.
38///
39/// A closed `enum` would be nicer to match on, but it would also fail to
40/// deserialize the moment the API returns a value this SDK predates — taking the
41/// whole response down with it. These types round-trip anything.
42macro_rules! string_enum {
43    (
44        $(#[$meta:meta])*
45        $name:ident {
46            $($(#[$variant_meta:meta])* $constant:ident => $value:literal),* $(,)?
47        }
48    ) => {
49        $(#[$meta])*
50        #[derive(Debug, Clone, PartialEq, Eq, Hash, ::serde::Serialize, ::serde::Deserialize)]
51        #[serde(transparent)]
52        pub struct $name(::std::borrow::Cow<'static, str>);
53
54        impl $name {
55            $($(#[$variant_meta])* pub const $constant: Self =
56                Self(::std::borrow::Cow::Borrowed($value));)*
57
58            /// The value as it appears on the wire.
59            pub fn as_str(&self) -> &str {
60                &self.0
61            }
62        }
63
64        impl ::std::fmt::Display for $name {
65            fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
66                f.write_str(&self.0)
67            }
68        }
69
70        impl AsRef<str> for $name {
71            fn as_ref(&self) -> &str {
72                &self.0
73            }
74        }
75
76        impl From<&str> for $name {
77            fn from(value: &str) -> Self {
78                Self(::std::borrow::Cow::Owned(value.to_string()))
79            }
80        }
81
82        impl From<String> for $name {
83            fn from(value: String) -> Self {
84                Self(::std::borrow::Cow::Owned(value))
85            }
86        }
87    };
88}
89
90pub(crate) use string_enum;
91
92string_enum! {
93    /// A VideoSDK region code, accepted as a room's geo-fence.
94    ///
95    /// Any region string is accepted — the API may add regions this SDK
96    /// predates — so this is a string newtype rather than a closed enum. The
97    /// associated constants enumerate the currently known regions.
98    ///
99    /// ```
100    /// # use videosdk::Region;
101    /// assert_eq!(Region::US001.as_str(), "us001");
102    /// assert_eq!(Region::from("mars001").as_str(), "mars001");
103    /// ```
104    Region {
105        /// `us001`
106        US001 => "us001",
107        /// `uk001`
108        UK001 => "uk001",
109        /// `sg001`
110        SG001 => "sg001",
111        /// `eu001`
112        EU001 => "eu001",
113        /// `uae001`
114        UAE001 => "uae001",
115        /// `in001`
116        IN001 => "in001",
117        /// `in002`
118        IN002 => "in002",
119        /// `in003`
120        IN003 => "in003",
121        /// `sg002`
122        SG002 => "sg002",
123        /// `us002`
124        US002 => "us002",
125        /// `aus001`
126        AUS001 => "aus001",
127        /// `dev1`
128        DEV1 => "dev1",
129    }
130}
131
132/* ---------------------------- composition layout ---------------------------- */
133
134/// A composition layout style, as it appears on the wire.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "UPPERCASE")]
137pub enum LayoutType {
138    /// Highlights the active participant.
139    Spotlight,
140    /// A main stage with a sidebar of participants.
141    Sidebar,
142    /// An even grid of participants.
143    Grid,
144}
145
146/// Which participant a layout prioritizes.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "UPPERCASE")]
149pub enum LayoutPriority {
150    /// Prioritize whoever is speaking.
151    Speaker,
152    /// Prioritize the pinned participant.
153    Pin,
154}
155
156/// A composition's orientation.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "lowercase")]
159pub enum Orientation {
160    /// Taller than wide.
161    Portrait,
162    /// Wider than tall.
163    Landscape,
164}
165
166/// A composition's theme.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "UPPERCASE")]
169pub enum Theme {
170    /// A dark theme.
171    Dark,
172    /// A light theme.
173    Light,
174    /// The account's default theme.
175    Default,
176}
177
178/// A recorder quality tier.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "lowercase")]
181pub enum CompositionQuality {
182    /// Lowest bitrate.
183    Low,
184    /// Medium bitrate.
185    Med,
186    /// High bitrate.
187    High,
188    /// Highest bitrate.
189    Ultra,
190}
191
192/// The wire layout configuration of a composition.
193#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(rename_all = "camelCase")]
195pub struct LayoutConfig {
196    /// The layout style.
197    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
198    pub kind: Option<LayoutType>,
199    /// Which participant the layout prioritizes.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub priority: Option<LayoutPriority>,
202    /// The number of tiles in a grid layout: 0-25, defaulting to 25.
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub grid_size: Option<u32>,
205}
206
207/// The wire composition configuration, shared by recording, HLS and livestream.
208#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct CompositionConfig {
211    /// The layout configuration.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub layout: Option<LayoutConfig>,
214    /// The composition's orientation.
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub orientation: Option<Orientation>,
217    /// The composition's theme.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub theme: Option<Theme>,
220    /// The recorder quality tier.
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub quality: Option<CompositionQuality>,
223}
224
225impl CompositionConfig {
226    /// Whether no field is set, i.e. this would serialize to `{}`.
227    pub fn is_empty(&self) -> bool {
228        self.layout.is_none()
229            && self.orientation.is_none()
230            && self.theme.is_none()
231            && self.quality.is_none()
232    }
233}
234
235/* -------------------------------- transcription ------------------------------ */
236
237/// A post-call AI summary configuration. Requires transcription to be enabled.
238#[derive(Debug, Clone, Default, Serialize, Deserialize)]
239pub struct SummaryConfig {
240    /// Whether to generate a summary.
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub enabled: Option<bool>,
243    /// A prompt steering the summary.
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub prompt: Option<String>,
246}
247
248/// A real-time transcription configuration.
249#[derive(Debug, Clone, Default, Serialize, Deserialize)]
250pub struct TranscriptionConfig {
251    /// Whether to transcribe.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub enabled: Option<bool>,
254    /// A post-call summary configuration.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub summary: Option<SummaryConfig>,
257}
258
259/// The behavior applied when a composition fails.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase")]
262pub struct OnFailureConfig {
263    /// The action to take. Currently only `close-room` is supported.
264    pub action: String,
265    /// Seconds to wait before applying the action. Capped at 150, default 150.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub wait_time: Option<u32>,
268}
269
270impl OnFailureConfig {
271    /// Close the room if the composition fails.
272    pub fn close_room(wait_time: Option<u32>) -> Self {
273        Self {
274            action: "close-room".to_string(),
275            wait_time,
276        }
277    }
278}
279
280/* -------------------------------- file formats ------------------------------- */
281
282/// The file format of a room or composite recording.
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(rename_all = "lowercase")]
285pub enum RecordingFileFormat {
286    /// MPEG-4. The default.
287    Mp4,
288    /// WebM.
289    Webm,
290}
291
292/// The file format of a participant recording.
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(rename_all = "lowercase")]
295pub enum ParticipantFileFormat {
296    /// MPEG-4.
297    Mp4,
298    /// WebM. The default.
299    Webm,
300    /// An HLS playlist.
301    M3u8,
302}
303
304/// The file format of a track recording.
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(rename_all = "lowercase")]
307pub enum TrackFileFormat {
308    /// MPEG-4.
309    Mp4,
310    /// WebM. The default.
311    Webm,
312    /// An HLS playlist.
313    M3u8,
314    /// MP3 audio.
315    Mp3,
316}
317
318/// The media kind of a track recording.
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(rename_all = "snake_case")]
321pub enum TrackKind {
322    /// The participant's microphone.
323    Audio,
324    /// The participant's camera.
325    Video,
326    /// Audio shared with the screen.
327    ScreenAudio,
328    /// The shared screen.
329    ScreenVideo,
330}
331
332/// A produced recording or output file.
333#[derive(Debug, Clone, Deserialize)]
334#[serde(rename_all = "camelCase")]
335pub struct RecordingFile {
336    /// Where the file can be downloaded.
337    pub file_url: Option<String>,
338    /// Any fields the server returned that this SDK does not model yet.
339    #[serde(flatten)]
340    pub extra: serde_json::Map<String, serde_json::Value>,
341}
342
343/// A single webhook delivery attempt.
344#[derive(Debug, Clone, Deserialize)]
345pub struct WebhookDelivery {
346    /// The event type that was delivered.
347    #[serde(rename = "type")]
348    pub kind: String,
349    /// Whether the delivery succeeded.
350    pub success: bool,
351}
352
353/// A summary of a resource's webhook delivery attempts.
354#[derive(Debug, Clone, Deserialize)]
355#[serde(rename_all = "camelCase")]
356pub struct WebhookDeliverySummary {
357    /// How many deliveries were attempted.
358    #[serde(default, deserialize_with = "crate::common::null_to_default")]
359    pub total_count: u32,
360    /// How many deliveries succeeded.
361    #[serde(default, deserialize_with = "crate::common::null_to_default")]
362    pub success_count: u32,
363    /// The individual delivery attempts.
364    #[serde(default, deserialize_with = "crate::common::null_to_default")]
365    pub data: Vec<WebhookDelivery>,
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use serde_json::json;
372
373    #[test]
374    fn enums_use_the_wire_casing_the_api_expects() {
375        assert_eq!(
376            serde_json::to_value(LayoutType::Grid).unwrap(),
377            json!("GRID")
378        );
379        assert_eq!(
380            serde_json::to_value(LayoutPriority::Speaker).unwrap(),
381            json!("SPEAKER")
382        );
383        assert_eq!(
384            serde_json::to_value(Orientation::Portrait).unwrap(),
385            json!("portrait")
386        );
387        assert_eq!(serde_json::to_value(Theme::Dark).unwrap(), json!("DARK"));
388        assert_eq!(
389            serde_json::to_value(CompositionQuality::Ultra).unwrap(),
390            json!("ultra")
391        );
392        assert_eq!(
393            serde_json::to_value(TrackKind::ScreenAudio).unwrap(),
394            json!("screen_audio")
395        );
396        assert_eq!(
397            serde_json::to_value(TrackFileFormat::M3u8).unwrap(),
398            json!("m3u8")
399        );
400        assert_eq!(
401            serde_json::to_value(RecordingFileFormat::Mp4).unwrap(),
402            json!("mp4")
403        );
404    }
405
406    #[test]
407    fn layout_config_serializes_type_not_kind() {
408        let config = LayoutConfig {
409            kind: Some(LayoutType::Sidebar),
410            priority: Some(LayoutPriority::Pin),
411            grid_size: None,
412        };
413        assert_eq!(
414            serde_json::to_value(config).unwrap(),
415            json!({"type": "SIDEBAR", "priority": "PIN"})
416        );
417    }
418
419    #[test]
420    fn an_empty_composition_config_is_empty() {
421        assert!(CompositionConfig::default().is_empty());
422        assert!(!CompositionConfig {
423            theme: Some(Theme::Light),
424            ..Default::default()
425        }
426        .is_empty());
427    }
428
429    #[test]
430    fn on_failure_helper_builds_the_documented_action() {
431        assert_eq!(
432            serde_json::to_value(OnFailureConfig::close_room(Some(30))).unwrap(),
433            json!({"action": "close-room", "waitTime": 30})
434        );
435    }
436
437    #[test]
438    fn region_is_transparent_on_the_wire() {
439        assert_eq!(serde_json::to_value(Region::IN002).unwrap(), json!("in002"));
440        let parsed: Region = serde_json::from_value(json!("us001")).unwrap();
441        assert_eq!(parsed, Region::US001);
442    }
443
444    #[test]
445    fn an_unknown_region_round_trips() {
446        let parsed: Region = serde_json::from_value(json!("future-region")).unwrap();
447        assert_eq!(parsed.as_str(), "future-region");
448        assert_eq!(
449            serde_json::to_value(&parsed).unwrap(),
450            json!("future-region")
451        );
452    }
453}