videosdk-server-sdk 0.1.0

Rust server SDK for the VideoSDK v2 REST APIs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
//! The rooms (meetings) API.

use std::sync::Arc;

use futures_util::Stream;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};

use crate::client::{CallOptions, Client};
use crate::common::{string_enum, Region, ResourceLinks};
use crate::error::Result;
use crate::pagination::{auto_page, paginate, ListParams, Page, PageFetcher};
use crate::query::QueryBuilder;
use crate::resources::escape;

const PATH: &str = "/v2/rooms";

string_enum! {
    /// What happens when a room's session ends.
    ///
    /// This rides on every [`Room`] response, so it is an open string newtype: a
    /// closed enum would fail to deserialize — taking the whole room with it —
    /// the moment the API adds a value this SDK predates.
    AutoCloseType {
        /// End the session. The default.
        SESSION_ENDS => "session-ends",
        /// End the session and deactivate the room.
        SESSION_END_AND_DEACTIVATE => "session-end-and-deactivate",
    }
}

/// A room-level webhook subscription, as carried on a [`Room`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoomWebhook {
    /// The HTTPS endpoint that receives room and session events (≤512 chars).
    #[serde(rename = "endPoint")]
    pub end_point: String,
    /// The subscribed event names. Wildcards like `session*` are accepted.
    #[serde(default, deserialize_with = "crate::common::null_to_default")]
    pub events: Vec<String>,
}

/// The auto-close behavior of a room's session, as carried on a [`Room`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutoCloseConfig {
    /// What happens when the session closes.
    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
    pub kind: Option<AutoCloseType>,
    /// The maximum session duration, in seconds.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub duration: Option<u32>,
}

/// Independently starts multiple compositions of the same type.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MultiComposition {
    /// Allow multiple concurrent recordings.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub recording: Option<bool>,
    /// Allow multiple concurrent HLS streams.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hls: Option<bool>,
    /// Allow multiple concurrent RTMP streams.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rtmp: Option<bool>,
}

/// Compositions to start automatically when a session begins.
///
/// Each entry mirrors the corresponding composition's start options and is
/// passed through to the API as-is.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AutoStartConfig {
    /// Start a recording when the session begins.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub recording: Option<Value>,
    /// Start an HLS stream when the session begins.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hls: Option<Value>,
    /// Start a composite composition when the session begins.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub composite: Option<Value>,
}

/// Subscribes a room to its session events.
#[derive(Debug, Clone)]
pub struct RoomWebhookInput {
    /// The HTTPS endpoint that receives room and session events (≤512 chars).
    pub url: String,
    /// The event names to subscribe to. Wildcards like `session*` are accepted.
    pub events: Vec<String>,
}

/// The auto-close behavior for a room's session.
#[derive(Debug, Clone, Default)]
pub struct AutoCloseConfigInput {
    /// Automatically close the session after this many seconds.
    pub after_inactive_seconds: Option<u32>,
    /// What happens when the session closes. Defaults to
    /// [`AutoCloseType::SESSION_ENDS`].
    pub kind: Option<AutoCloseType>,
}

/// The parameters for [`RoomsResource::create`].
#[derive(Debug, Clone, Default)]
pub struct RoomCreateParams {
    /// A caller-supplied stable id. Creation is idempotent on this value.
    pub custom_room_id: Option<String>,
    /// Pins the room to a region. Falls back to the API key's region.
    pub geo_fence: Option<Region>,
    /// Subscribes the room to its session events.
    pub webhook: Option<RoomWebhookInput>,
    /// The auto-close behavior when the session ends.
    pub auto_close_config: Option<AutoCloseConfigInput>,
    /// Compositions to start automatically when a session begins.
    pub auto_start_config: Option<AutoStartConfig>,
    /// Independently starts multiple compositions of the same type.
    pub multi_composition: Option<MultiComposition>,
    /// Restricts which participant ids may join.
    pub allowed_participant_ids: Option<Vec<String>>,
}

/// The wire body of a create request. The friendly parameters above rename onto
/// this: `webhook.url` becomes `webhook.endPoint`, and
/// `auto_close_config.after_inactive_seconds` becomes `autoCloseConfig.duration`.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct RoomCreateWire {
    #[serde(skip_serializing_if = "Option::is_none")]
    custom_room_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    geo_fence: Option<Region>,
    #[serde(skip_serializing_if = "Option::is_none")]
    auto_start_config: Option<AutoStartConfig>,
    #[serde(skip_serializing_if = "Option::is_none")]
    multi_composition: Option<MultiComposition>,
    #[serde(skip_serializing_if = "Option::is_none")]
    allowed_participant_ids: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    webhook: Option<RoomWebhook>,
    #[serde(skip_serializing_if = "Option::is_none")]
    auto_close_config: Option<AutoCloseConfig>,
}

impl From<RoomCreateParams> for RoomCreateWire {
    fn from(params: RoomCreateParams) -> Self {
        Self {
            custom_room_id: params.custom_room_id,
            geo_fence: params.geo_fence,
            auto_start_config: params.auto_start_config,
            multi_composition: params.multi_composition,
            allowed_participant_ids: params.allowed_participant_ids,
            webhook: params.webhook.map(|webhook| RoomWebhook {
                end_point: webhook.url,
                events: webhook.events,
            }),
            auto_close_config: params.auto_close_config.map(|config| AutoCloseConfig {
                // The server requires a type whenever the config is present.
                kind: Some(config.kind.unwrap_or(AutoCloseType::SESSION_ENDS)),
                duration: config.after_inactive_seconds,
            }),
        }
    }
}

/// A room, a.k.a. a "meeting": a durable id that hosts live sessions.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Room {
    /// The room's database id.
    pub id: String,
    /// The VideoSDK room id, formatted `xxxx-xxxx-xxxx`.
    #[serde(default, deserialize_with = "crate::common::null_to_default")]
    pub room_id: String,
    /// The caller-supplied stable id, if one was given.
    pub custom_room_id: Option<String>,
    /// The API key that owns the room.
    pub api_key: Option<String>,
    /// The region the room is pinned to.
    pub geo_fence: Option<Region>,
    /// Whether the room has been deactivated.
    #[serde(default, deserialize_with = "crate::common::null_to_default")]
    pub disabled: bool,
    /// The room's webhook subscription.
    pub webhook: Option<RoomWebhook>,
    /// The room's auto-close behavior.
    pub auto_close_config: Option<AutoCloseConfig>,
    /// Compositions started automatically when a session begins.
    ///
    /// Deliberately untyped: it mirrors whichever composition options were set.
    pub auto_start_config: Option<Value>,
    /// Whether multiple compositions of the same type may run concurrently.
    pub multi_composition: Option<Value>,
    /// The participant ids permitted to join.
    #[serde(default, deserialize_with = "crate::common::null_to_default")]
    pub allowed_participant_ids: Vec<String>,
    /// When the room was created.
    pub created_at: Option<String>,
    /// When the room was last updated.
    pub updated_at: Option<String>,
    /// HATEOAS-style links to related resources.
    #[serde(default, deserialize_with = "crate::common::null_to_default")]
    pub links: ResourceLinks,
    /// Any fields the server returned that this SDK does not model yet.
    #[serde(flatten)]
    pub extra: Map<String, Value>,
}

/// The result of [`RoomsResource::validate`].
#[derive(Debug, Clone)]
pub struct RoomValidation {
    /// Whether the id belongs to the caller: it matched a `room_id` or a
    /// `custom_room_id`.
    pub valid: bool,
    /// The room id that was validated, echoed back.
    pub room_id: String,
    /// The resolved room. Present only when [`valid`](RoomValidation::valid).
    pub room: Option<Room>,
}

/// The query parameters for [`RoomsResource::list`].
#[derive(Debug, Clone, Default)]
pub struct RoomListParams {
    /// The 1-based page number.
    pub page: Option<u32>,
    /// Items per page.
    pub per_page: Option<u32>,
    /// An opaque cursor from a previous page. Overrides `page` and `per_page`.
    pub cursor: Option<String>,
    /// An exact match on `room_id` or `custom_room_id`.
    pub query: Option<String>,
    /// Scopes the listing to a specific user. Admin tokens only.
    pub user_id: Option<String>,
}

impl RoomListParams {
    fn pagination(&self) -> ListParams {
        ListParams {
            page: self.page,
            per_page: self.per_page,
            cursor: self.cursor.clone(),
        }
    }
}

/// The rooms (meetings) API. Reached via [`Client::rooms`].
#[derive(Debug, Clone, Copy)]
pub struct RoomsResource<'a> {
    client: &'a Client,
}

impl<'a> RoomsResource<'a> {
    pub(crate) fn new(client: &'a Client) -> Self {
        Self { client }
    }

    /// Creates a room. Idempotent when `custom_room_id` is supplied.
    pub async fn create(&self, params: RoomCreateParams) -> Result<Room> {
        let body = RoomCreateWire::from(params);
        self.client
            .json(Method::POST, PATH, CallOptions::json(&body)?)
            .await
    }

    /// Lists rooms, one page at a time.
    pub async fn list(&self, params: RoomListParams) -> Result<Page<Room>> {
        paginate(self.fetcher(&params), &params.pagination(), "data", None).await
    }

    /// Lists rooms, transparently fetching every page.
    pub fn list_stream(&self, params: RoomListParams) -> impl Stream<Item = Result<Room>> + Send {
        auto_page(self.fetcher(&params), params.pagination(), "data", None)
    }

    /// Fetches a room by `id`, `room_id`, or `custom_room_id`.
    pub async fn get(&self, room_id: &str) -> Result<Room> {
        let path = format!("{PATH}/{}", escape(room_id));
        self.client
            .json(Method::GET, &path, CallOptions::new())
            .await
    }

    /// Reports whether a room id belongs to the caller.
    ///
    /// Returns `valid: false` rather than an error for an unknown id — the API
    /// answers those with 400, 402 or 404.
    pub async fn validate(&self, room_id: &str) -> Result<RoomValidation> {
        let path = format!("{PATH}/validate/{}", escape(room_id));
        match self
            .client
            .json::<Room>(Method::GET, &path, CallOptions::new())
            .await
        {
            Ok(room) => Ok(RoomValidation {
                valid: true,
                room_id: if room.room_id.is_empty() {
                    room_id.to_string()
                } else {
                    room.room_id.clone()
                },
                room: Some(room),
            }),
            Err(err) if matches!(err.status(), Some(400 | 402 | 404)) => Ok(RoomValidation {
                valid: false,
                room_id: room_id.to_string(),
                room: None,
            }),
            Err(err) => Err(err),
        }
    }

    /// Deactivates a room and returns the now-disabled room.
    ///
    /// Unlike [`get`](RoomsResource::get) and
    /// [`validate`](RoomsResource::validate), this resolves only the VideoSDK
    /// `room_id`; a `custom_room_id` is rejected with a 402.
    pub async fn end(&self, room_id: &str) -> Result<Room> {
        let body = serde_json::json!({ "roomId": room_id });
        self.client
            .json(
                Method::POST,
                "/v2/rooms/deactivate",
                CallOptions::json(&body)?,
            )
            .await
    }

    fn fetcher(&self, params: &RoomListParams) -> PageFetcher {
        let client = self.client.clone();
        let query = params.query.clone();
        let user_id = params.user_id.clone();

        Arc::new(move |page, per_page| {
            let client = client.clone();
            let query = query.clone();
            let user_id = user_id.clone();
            Box::pin(async move {
                let params = QueryBuilder::new()
                    .opt("page", page)
                    .opt("perPage", per_page)
                    .opt_str("query", query.as_deref())
                    .opt_str("userId", user_id.as_deref())
                    .into_pairs();
                client
                    .json::<Value>(Method::GET, PATH, CallOptions::new().query(params))
                    .await
            })
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn wire(params: RoomCreateParams) -> Value {
        serde_json::to_value(RoomCreateWire::from(params)).unwrap()
    }

    #[test]
    fn an_empty_create_sends_an_empty_body() {
        assert_eq!(wire(RoomCreateParams::default()), json!({}));
    }

    #[test]
    fn webhook_url_is_renamed_to_end_point() {
        let body = wire(RoomCreateParams {
            webhook: Some(RoomWebhookInput {
                url: "https://example.com/hook".into(),
                events: vec!["session-started".into()],
            }),
            ..Default::default()
        });
        assert_eq!(
            body["webhook"],
            json!({"endPoint": "https://example.com/hook", "events": ["session-started"]})
        );
        assert!(body["webhook"].get("url").is_none());
    }

    #[test]
    fn auto_close_seconds_are_renamed_to_duration_with_a_default_type() {
        let body = wire(RoomCreateParams {
            auto_close_config: Some(AutoCloseConfigInput {
                after_inactive_seconds: Some(300),
                kind: None,
            }),
            ..Default::default()
        });
        // The server rejects an auto-close config with no `type`.
        assert_eq!(
            body["autoCloseConfig"],
            json!({"type": "session-ends", "duration": 300})
        );
    }

    #[test]
    fn an_explicit_auto_close_type_is_preserved() {
        let body = wire(RoomCreateParams {
            auto_close_config: Some(AutoCloseConfigInput {
                after_inactive_seconds: None,
                kind: Some(AutoCloseType::SESSION_END_AND_DEACTIVATE),
            }),
            ..Default::default()
        });
        assert_eq!(
            body["autoCloseConfig"],
            json!({"type": "session-end-and-deactivate"})
        );
    }

    #[test]
    fn remaining_fields_pass_through_camel_cased() {
        let body = wire(RoomCreateParams {
            custom_room_id: Some("my-room".into()),
            geo_fence: Some(Region::IN002),
            allowed_participant_ids: Some(vec!["p-1".into()]),
            multi_composition: Some(MultiComposition {
                recording: Some(true),
                ..Default::default()
            }),
            ..Default::default()
        });
        assert_eq!(
            body,
            json!({
                "customRoomId": "my-room",
                "geoFence": "in002",
                "allowedParticipantIds": ["p-1"],
                "multiComposition": {"recording": true},
            })
        );
    }

    #[test]
    fn a_room_keeps_unmodeled_fields_in_extra() {
        let room: Room = serde_json::from_value(json!({
            "id": "db-1",
            "roomId": "abcd-efgh-ijkl",
            "somethingNew": 42,
        }))
        .unwrap();
        assert_eq!(room.room_id, "abcd-efgh-ijkl");
        assert!(!room.disabled);
        assert_eq!(room.extra.get("somethingNew"), Some(&json!(42)));
    }
}