1use 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 AutoCloseType {
26 SESSION_ENDS => "session-ends",
28 SESSION_END_AND_DEACTIVATE => "session-end-and-deactivate",
30 }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct RoomWebhook {
36 #[serde(rename = "endPoint")]
38 pub end_point: String,
39 #[serde(default, deserialize_with = "crate::common::null_to_default")]
41 pub events: Vec<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AutoCloseConfig {
47 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
49 pub kind: Option<AutoCloseType>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub duration: Option<u32>,
53}
54
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct MultiComposition {
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub recording: Option<bool>,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub hls: Option<bool>,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub rtmp: Option<bool>,
68}
69
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct AutoStartConfig {
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub recording: Option<Value>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub hls: Option<Value>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub composite: Option<Value>,
85}
86
87#[derive(Debug, Clone)]
89pub struct RoomWebhookInput {
90 pub url: String,
92 pub events: Vec<String>,
94}
95
96#[derive(Debug, Clone, Default)]
98pub struct AutoCloseConfigInput {
99 pub after_inactive_seconds: Option<u32>,
101 pub kind: Option<AutoCloseType>,
104}
105
106#[derive(Debug, Clone, Default)]
108pub struct RoomCreateParams {
109 pub custom_room_id: Option<String>,
111 pub geo_fence: Option<Region>,
113 pub webhook: Option<RoomWebhookInput>,
115 pub auto_close_config: Option<AutoCloseConfigInput>,
117 pub auto_start_config: Option<AutoStartConfig>,
119 pub multi_composition: Option<MultiComposition>,
121 pub allowed_participant_ids: Option<Vec<String>>,
123}
124
125#[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 kind: Some(config.kind.unwrap_or(AutoCloseType::SESSION_ENDS)),
162 duration: config.after_inactive_seconds,
163 }),
164 }
165 }
166}
167
168#[derive(Debug, Clone, Deserialize)]
170#[serde(rename_all = "camelCase")]
171pub struct Room {
172 pub id: String,
174 #[serde(default, deserialize_with = "crate::common::null_to_default")]
176 pub room_id: String,
177 pub custom_room_id: Option<String>,
179 pub api_key: Option<String>,
181 pub geo_fence: Option<Region>,
183 #[serde(default, deserialize_with = "crate::common::null_to_default")]
185 pub disabled: bool,
186 pub webhook: Option<RoomWebhook>,
188 pub auto_close_config: Option<AutoCloseConfig>,
190 pub auto_start_config: Option<Value>,
194 pub multi_composition: Option<Value>,
196 #[serde(default, deserialize_with = "crate::common::null_to_default")]
198 pub allowed_participant_ids: Vec<String>,
199 pub created_at: Option<String>,
201 pub updated_at: Option<String>,
203 #[serde(default, deserialize_with = "crate::common::null_to_default")]
205 pub links: ResourceLinks,
206 #[serde(flatten)]
208 pub extra: Map<String, Value>,
209}
210
211#[derive(Debug, Clone)]
213pub struct RoomValidation {
214 pub valid: bool,
217 pub room_id: String,
219 pub room: Option<Room>,
221}
222
223#[derive(Debug, Clone, Default)]
225pub struct RoomListParams {
226 pub page: Option<u32>,
228 pub per_page: Option<u32>,
230 pub cursor: Option<String>,
232 pub query: Option<String>,
234 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#[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 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 pub async fn list(&self, params: RoomListParams) -> Result<Page<Room>> {
269 paginate(self.fetcher(¶ms), ¶ms.pagination(), "data", None).await
270 }
271
272 pub fn list_stream(&self, params: RoomListParams) -> impl Stream<Item = Result<Room>> + Send {
274 auto_page(self.fetcher(¶ms), params.pagination(), "data", None)
275 }
276
277 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 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 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 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}