Skip to main content

videosdk/resources/
rooms.rs

1//! The rooms (meetings) API.
2
3use std::sync::Arc;
4
5use futures_util::Stream;
6use reqwest::Method;
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9
10use crate::client::{CallOptions, Client};
11use crate::common::{string_enum, Region, ResourceLinks};
12use crate::error::Result;
13use crate::pagination::{auto_page, paginate, ListParams, Page, PageFetcher};
14use crate::query::QueryBuilder;
15use crate::resources::escape;
16
17const PATH: &str = "/v2/rooms";
18
19string_enum! {
20    /// What happens when a room's session ends.
21    ///
22    /// This rides on every [`Room`] response, so it is an open string newtype: a
23    /// closed enum would fail to deserialize — taking the whole room with it —
24    /// the moment the API adds a value this SDK predates.
25    AutoCloseType {
26        /// End the session. The default.
27        SESSION_ENDS => "session-ends",
28        /// End the session and deactivate the room.
29        SESSION_END_AND_DEACTIVATE => "session-end-and-deactivate",
30    }
31}
32
33/// A room-level webhook subscription, as carried on a [`Room`].
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct RoomWebhook {
36    /// The HTTPS endpoint that receives room and session events (≤512 chars).
37    #[serde(rename = "endPoint")]
38    pub end_point: String,
39    /// The subscribed event names. Wildcards like `session*` are accepted.
40    #[serde(default, deserialize_with = "crate::common::null_to_default")]
41    pub events: Vec<String>,
42}
43
44/// The auto-close behavior of a room's session, as carried on a [`Room`].
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AutoCloseConfig {
47    /// What happens when the session closes.
48    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
49    pub kind: Option<AutoCloseType>,
50    /// The maximum session duration, in seconds.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub duration: Option<u32>,
53}
54
55/// Independently starts multiple compositions of the same type.
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct MultiComposition {
59    /// Allow multiple concurrent recordings.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub recording: Option<bool>,
62    /// Allow multiple concurrent HLS streams.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub hls: Option<bool>,
65    /// Allow multiple concurrent RTMP streams.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub rtmp: Option<bool>,
68}
69
70/// Compositions to start automatically when a session begins.
71///
72/// Each entry mirrors the corresponding composition's start options and is
73/// passed through to the API as-is.
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct AutoStartConfig {
76    /// Start a recording when the session begins.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub recording: Option<Value>,
79    /// Start an HLS stream when the session begins.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub hls: Option<Value>,
82    /// Start a composite composition when the session begins.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub composite: Option<Value>,
85}
86
87/// Subscribes a room to its session events.
88#[derive(Debug, Clone)]
89pub struct RoomWebhookInput {
90    /// The HTTPS endpoint that receives room and session events (≤512 chars).
91    pub url: String,
92    /// The event names to subscribe to. Wildcards like `session*` are accepted.
93    pub events: Vec<String>,
94}
95
96/// The auto-close behavior for a room's session.
97#[derive(Debug, Clone, Default)]
98pub struct AutoCloseConfigInput {
99    /// Automatically close the session after this many seconds.
100    pub after_inactive_seconds: Option<u32>,
101    /// What happens when the session closes. Defaults to
102    /// [`AutoCloseType::SESSION_ENDS`].
103    pub kind: Option<AutoCloseType>,
104}
105
106/// The parameters for [`RoomsResource::create`].
107#[derive(Debug, Clone, Default)]
108pub struct RoomCreateParams {
109    /// A caller-supplied stable id. Creation is idempotent on this value.
110    pub custom_room_id: Option<String>,
111    /// Pins the room to a region. Falls back to the API key's region.
112    pub geo_fence: Option<Region>,
113    /// Subscribes the room to its session events.
114    pub webhook: Option<RoomWebhookInput>,
115    /// The auto-close behavior when the session ends.
116    pub auto_close_config: Option<AutoCloseConfigInput>,
117    /// Compositions to start automatically when a session begins.
118    pub auto_start_config: Option<AutoStartConfig>,
119    /// Independently starts multiple compositions of the same type.
120    pub multi_composition: Option<MultiComposition>,
121    /// Restricts which participant ids may join.
122    pub allowed_participant_ids: Option<Vec<String>>,
123}
124
125/// The wire body of a create request. The friendly parameters above rename onto
126/// this: `webhook.url` becomes `webhook.endPoint`, and
127/// `auto_close_config.after_inactive_seconds` becomes `autoCloseConfig.duration`.
128#[derive(Debug, Serialize)]
129#[serde(rename_all = "camelCase")]
130struct RoomCreateWire {
131    #[serde(skip_serializing_if = "Option::is_none")]
132    custom_room_id: Option<String>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    geo_fence: Option<Region>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    auto_start_config: Option<AutoStartConfig>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    multi_composition: Option<MultiComposition>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    allowed_participant_ids: Option<Vec<String>>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    webhook: Option<RoomWebhook>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    auto_close_config: Option<AutoCloseConfig>,
145}
146
147impl From<RoomCreateParams> for RoomCreateWire {
148    fn from(params: RoomCreateParams) -> Self {
149        Self {
150            custom_room_id: params.custom_room_id,
151            geo_fence: params.geo_fence,
152            auto_start_config: params.auto_start_config,
153            multi_composition: params.multi_composition,
154            allowed_participant_ids: params.allowed_participant_ids,
155            webhook: params.webhook.map(|webhook| RoomWebhook {
156                end_point: webhook.url,
157                events: webhook.events,
158            }),
159            auto_close_config: params.auto_close_config.map(|config| AutoCloseConfig {
160                // The server requires a type whenever the config is present.
161                kind: Some(config.kind.unwrap_or(AutoCloseType::SESSION_ENDS)),
162                duration: config.after_inactive_seconds,
163            }),
164        }
165    }
166}
167
168/// A room, a.k.a. a "meeting": a durable id that hosts live sessions.
169#[derive(Debug, Clone, Deserialize)]
170#[serde(rename_all = "camelCase")]
171pub struct Room {
172    /// The room's database id.
173    pub id: String,
174    /// The VideoSDK room id, formatted `xxxx-xxxx-xxxx`.
175    #[serde(default, deserialize_with = "crate::common::null_to_default")]
176    pub room_id: String,
177    /// The caller-supplied stable id, if one was given.
178    pub custom_room_id: Option<String>,
179    /// The API key that owns the room.
180    pub api_key: Option<String>,
181    /// The region the room is pinned to.
182    pub geo_fence: Option<Region>,
183    /// Whether the room has been deactivated.
184    #[serde(default, deserialize_with = "crate::common::null_to_default")]
185    pub disabled: bool,
186    /// The room's webhook subscription.
187    pub webhook: Option<RoomWebhook>,
188    /// The room's auto-close behavior.
189    pub auto_close_config: Option<AutoCloseConfig>,
190    /// Compositions started automatically when a session begins.
191    ///
192    /// Deliberately untyped: it mirrors whichever composition options were set.
193    pub auto_start_config: Option<Value>,
194    /// Whether multiple compositions of the same type may run concurrently.
195    pub multi_composition: Option<Value>,
196    /// The participant ids permitted to join.
197    #[serde(default, deserialize_with = "crate::common::null_to_default")]
198    pub allowed_participant_ids: Vec<String>,
199    /// When the room was created.
200    pub created_at: Option<String>,
201    /// When the room was last updated.
202    pub updated_at: Option<String>,
203    /// HATEOAS-style links to related resources.
204    #[serde(default, deserialize_with = "crate::common::null_to_default")]
205    pub links: ResourceLinks,
206    /// Any fields the server returned that this SDK does not model yet.
207    #[serde(flatten)]
208    pub extra: Map<String, Value>,
209}
210
211/// The result of [`RoomsResource::validate`].
212#[derive(Debug, Clone)]
213pub struct RoomValidation {
214    /// Whether the id belongs to the caller: it matched a `room_id` or a
215    /// `custom_room_id`.
216    pub valid: bool,
217    /// The room id that was validated, echoed back.
218    pub room_id: String,
219    /// The resolved room. Present only when [`valid`](RoomValidation::valid).
220    pub room: Option<Room>,
221}
222
223/// The query parameters for [`RoomsResource::list`].
224#[derive(Debug, Clone, Default)]
225pub struct RoomListParams {
226    /// The 1-based page number.
227    pub page: Option<u32>,
228    /// Items per page.
229    pub per_page: Option<u32>,
230    /// An opaque cursor from a previous page. Overrides `page` and `per_page`.
231    pub cursor: Option<String>,
232    /// An exact match on `room_id` or `custom_room_id`.
233    pub query: Option<String>,
234    /// Scopes the listing to a specific user. Admin tokens only.
235    pub user_id: Option<String>,
236}
237
238impl RoomListParams {
239    fn pagination(&self) -> ListParams {
240        ListParams {
241            page: self.page,
242            per_page: self.per_page,
243            cursor: self.cursor.clone(),
244        }
245    }
246}
247
248/// The rooms (meetings) API. Reached via [`Client::rooms`].
249#[derive(Debug, Clone, Copy)]
250pub struct RoomsResource<'a> {
251    client: &'a Client,
252}
253
254impl<'a> RoomsResource<'a> {
255    pub(crate) fn new(client: &'a Client) -> Self {
256        Self { client }
257    }
258
259    /// Creates a room. Idempotent when `custom_room_id` is supplied.
260    pub async fn create(&self, params: RoomCreateParams) -> Result<Room> {
261        let body = RoomCreateWire::from(params);
262        self.client
263            .json(Method::POST, PATH, CallOptions::json(&body)?)
264            .await
265    }
266
267    /// Lists rooms, one page at a time.
268    pub async fn list(&self, params: RoomListParams) -> Result<Page<Room>> {
269        paginate(self.fetcher(&params), &params.pagination(), "data", None).await
270    }
271
272    /// Lists rooms, transparently fetching every page.
273    pub fn list_stream(&self, params: RoomListParams) -> impl Stream<Item = Result<Room>> + Send {
274        auto_page(self.fetcher(&params), params.pagination(), "data", None)
275    }
276
277    /// Fetches a room by `id`, `room_id`, or `custom_room_id`.
278    pub async fn get(&self, room_id: &str) -> Result<Room> {
279        let path = format!("{PATH}/{}", escape(room_id));
280        self.client
281            .json(Method::GET, &path, CallOptions::new())
282            .await
283    }
284
285    /// Reports whether a room id belongs to the caller.
286    ///
287    /// Returns `valid: false` rather than an error for an unknown id — the API
288    /// answers those with 400, 402 or 404.
289    pub async fn validate(&self, room_id: &str) -> Result<RoomValidation> {
290        let path = format!("{PATH}/validate/{}", escape(room_id));
291        match self
292            .client
293            .json::<Room>(Method::GET, &path, CallOptions::new())
294            .await
295        {
296            Ok(room) => Ok(RoomValidation {
297                valid: true,
298                room_id: if room.room_id.is_empty() {
299                    room_id.to_string()
300                } else {
301                    room.room_id.clone()
302                },
303                room: Some(room),
304            }),
305            Err(err) if matches!(err.status(), Some(400 | 402 | 404)) => Ok(RoomValidation {
306                valid: false,
307                room_id: room_id.to_string(),
308                room: None,
309            }),
310            Err(err) => Err(err),
311        }
312    }
313
314    /// Deactivates a room and returns the now-disabled room.
315    ///
316    /// Unlike [`get`](RoomsResource::get) and
317    /// [`validate`](RoomsResource::validate), this resolves only the VideoSDK
318    /// `room_id`; a `custom_room_id` is rejected with a 402.
319    pub async fn end(&self, room_id: &str) -> Result<Room> {
320        let body = serde_json::json!({ "roomId": room_id });
321        self.client
322            .json(
323                Method::POST,
324                "/v2/rooms/deactivate",
325                CallOptions::json(&body)?,
326            )
327            .await
328    }
329
330    fn fetcher(&self, params: &RoomListParams) -> PageFetcher {
331        let client = self.client.clone();
332        let query = params.query.clone();
333        let user_id = params.user_id.clone();
334
335        Arc::new(move |page, per_page| {
336            let client = client.clone();
337            let query = query.clone();
338            let user_id = user_id.clone();
339            Box::pin(async move {
340                let params = QueryBuilder::new()
341                    .opt("page", page)
342                    .opt("perPage", per_page)
343                    .opt_str("query", query.as_deref())
344                    .opt_str("userId", user_id.as_deref())
345                    .into_pairs();
346                client
347                    .json::<Value>(Method::GET, PATH, CallOptions::new().query(params))
348                    .await
349            })
350        })
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use serde_json::json;
358
359    fn wire(params: RoomCreateParams) -> Value {
360        serde_json::to_value(RoomCreateWire::from(params)).unwrap()
361    }
362
363    #[test]
364    fn an_empty_create_sends_an_empty_body() {
365        assert_eq!(wire(RoomCreateParams::default()), json!({}));
366    }
367
368    #[test]
369    fn webhook_url_is_renamed_to_end_point() {
370        let body = wire(RoomCreateParams {
371            webhook: Some(RoomWebhookInput {
372                url: "https://example.com/hook".into(),
373                events: vec!["session-started".into()],
374            }),
375            ..Default::default()
376        });
377        assert_eq!(
378            body["webhook"],
379            json!({"endPoint": "https://example.com/hook", "events": ["session-started"]})
380        );
381        assert!(body["webhook"].get("url").is_none());
382    }
383
384    #[test]
385    fn auto_close_seconds_are_renamed_to_duration_with_a_default_type() {
386        let body = wire(RoomCreateParams {
387            auto_close_config: Some(AutoCloseConfigInput {
388                after_inactive_seconds: Some(300),
389                kind: None,
390            }),
391            ..Default::default()
392        });
393        // The server rejects an auto-close config with no `type`.
394        assert_eq!(
395            body["autoCloseConfig"],
396            json!({"type": "session-ends", "duration": 300})
397        );
398    }
399
400    #[test]
401    fn an_explicit_auto_close_type_is_preserved() {
402        let body = wire(RoomCreateParams {
403            auto_close_config: Some(AutoCloseConfigInput {
404                after_inactive_seconds: None,
405                kind: Some(AutoCloseType::SESSION_END_AND_DEACTIVATE),
406            }),
407            ..Default::default()
408        });
409        assert_eq!(
410            body["autoCloseConfig"],
411            json!({"type": "session-end-and-deactivate"})
412        );
413    }
414
415    #[test]
416    fn remaining_fields_pass_through_camel_cased() {
417        let body = wire(RoomCreateParams {
418            custom_room_id: Some("my-room".into()),
419            geo_fence: Some(Region::IN002),
420            allowed_participant_ids: Some(vec!["p-1".into()]),
421            multi_composition: Some(MultiComposition {
422                recording: Some(true),
423                ..Default::default()
424            }),
425            ..Default::default()
426        });
427        assert_eq!(
428            body,
429            json!({
430                "customRoomId": "my-room",
431                "geoFence": "in002",
432                "allowedParticipantIds": ["p-1"],
433                "multiComposition": {"recording": true},
434            })
435        );
436    }
437
438    #[test]
439    fn a_room_keeps_unmodeled_fields_in_extra() {
440        let room: Room = serde_json::from_value(json!({
441            "id": "db-1",
442            "roomId": "abcd-efgh-ijkl",
443            "somethingNew": 42,
444        }))
445        .unwrap();
446        assert_eq!(room.room_id, "abcd-efgh-ijkl");
447        assert!(!room.disabled);
448        assert_eq!(room.extra.get("somethingNew"), Some(&json!(42)));
449    }
450}