1use std::time::Duration;
4
5use reqwest::{Method, Url};
6use serde::{Deserialize, Serialize};
7use serde_json::{Map, Value};
8
9use crate::client::{CallOptions, Client};
10use crate::error::{Error, Result};
11use crate::query::QueryBuilder;
12use crate::resources::escape;
13use crate::resources::random_uuid;
14use crate::resources::session_utils::resolve_active_session_id;
15
16fn build_ingress_url(base: &str, path: &str, params: &[(&str, &str)]) -> Result<String> {
18 let mut url = Url::parse(&format!("{}{path}", base.trim_end_matches('/')))
19 .map_err(|e| Error::config(format!("invalid base URL {base:?}: {e}")))?;
20
21 let pairs: Vec<_> = params
22 .iter()
23 .filter(|(_, value)| !value.is_empty())
24 .collect();
25 if !pairs.is_empty() {
26 let mut query = url.query_pairs_mut();
27 for (name, value) in pairs {
28 query.append_pair(name, value);
29 }
30 }
31 Ok(url.into())
32}
33
34#[derive(Debug, Clone, Default)]
38pub struct CreateWhipParams {
39 pub participant_id: Option<String>,
41 pub name: Option<String>,
43 pub expires_in: Option<Duration>,
45 pub use_existing_peer: Option<bool>,
47}
48
49#[derive(Debug, Clone)]
56pub struct WhipIngress {
57 pub url: String,
59 pub token: String,
61 pub stream_key: String,
64 pub room_id: String,
66 pub participant_id: String,
68 pub display_name: Option<String>,
70 pub session_id: Option<String>,
73}
74
75#[derive(Debug, Clone, Copy)]
77pub struct WhipResource<'a> {
78 client: &'a Client,
79}
80
81impl<'a> WhipResource<'a> {
82 pub(crate) fn new(client: &'a Client) -> Self {
83 Self { client }
84 }
85
86 pub fn create(&self, room_id: &str, params: CreateWhipParams) -> Result<WhipIngress> {
91 let participant_id = params
92 .participant_id
93 .unwrap_or_else(|| format!("whip-{}", random_uuid()));
94 let token = self
95 .client
96 .mint_api_token(params.expires_in.unwrap_or_default())?;
97
98 let url = build_ingress_url(
99 self.client.base_url(),
100 "/v2/whip",
101 &[
102 ("roomId", room_id),
103 ("participantId", &participant_id),
104 ("displayName", params.name.as_deref().unwrap_or("")),
105 (
106 "useExistingPeer",
107 if params.use_existing_peer == Some(true) {
108 "true"
109 } else {
110 ""
111 },
112 ),
113 ],
114 )?;
115
116 Ok(WhipIngress {
117 url,
118 stream_key: token.clone(),
119 token,
120 room_id: room_id.to_string(),
121 participant_id,
122 display_name: params.name,
123 session_id: None,
124 })
125 }
126
127 pub async fn delete(&self, target: &WhipIngress) -> Result<()> {
132 let session_id = match &target.session_id {
133 Some(session_id) => session_id.clone(),
134 None => resolve_active_session_id(self.client, &target.room_id)
135 .await?
136 .ok_or_else(|| {
137 Error::not_found(format!("no active session for room {}", target.room_id))
138 })?,
139 };
140
141 let query = QueryBuilder::new()
142 .str_val("participantId", &target.participant_id)
143 .opt_str("displayName", target.display_name.as_deref())
144 .into_pairs();
145 let path = format!("/v2/whip/sessions/{}", escape(&session_id));
146 self.client
147 .none(Method::DELETE, &path, CallOptions::new().query(query))
148 .await
149 }
150}
151
152#[derive(Debug, Clone)]
156pub struct WhepSource {
157 pub participant_id: String,
159}
160
161#[derive(Debug, Clone, Default)]
163pub struct CreateWhepParams {
164 pub participant_id: Option<String>,
167 pub source: Option<WhepSource>,
169 pub expires_in: Option<Duration>,
171}
172
173#[derive(Debug, Clone)]
177pub struct WhepPlayback {
178 pub url: String,
180 pub token: String,
182 pub room_id: String,
184 pub participant_id: String,
186 pub remote_peer_id: Option<String>,
188 pub session_id: Option<String>,
190}
191
192#[derive(Debug, Clone, Copy)]
195pub struct WhepResource<'a> {
196 client: &'a Client,
197}
198
199impl<'a> WhepResource<'a> {
200 pub(crate) fn new(client: &'a Client) -> Self {
201 Self { client }
202 }
203
204 pub fn create(&self, room_id: &str, params: CreateWhepParams) -> Result<WhepPlayback> {
209 let participant_id = params
210 .participant_id
211 .unwrap_or_else(|| format!("whep-{}", random_uuid()));
212 let remote_peer_id = params.source.map(|source| source.participant_id);
213 let token = self
214 .client
215 .mint_api_token(params.expires_in.unwrap_or_default())?;
216
217 let url = build_ingress_url(
218 self.client.base_url(),
219 "/v2/whep",
220 &[
221 ("roomId", room_id),
222 ("participantId", &participant_id),
223 ("remotePeerId", remote_peer_id.as_deref().unwrap_or("")),
224 ],
225 )?;
226
227 Ok(WhepPlayback {
228 url,
229 token,
230 room_id: room_id.to_string(),
231 participant_id,
232 remote_peer_id,
233 session_id: None,
234 })
235 }
236
237 pub async fn delete(&self, target: &WhepPlayback) -> Result<()> {
239 let session_id = match &target.session_id {
240 Some(session_id) => session_id.clone(),
241 None => resolve_active_session_id(self.client, &target.room_id)
242 .await?
243 .ok_or_else(|| {
244 Error::not_found(format!("no active session for room {}", target.room_id))
245 })?,
246 };
247
248 let query = QueryBuilder::new()
249 .str_val("participantId", &target.participant_id)
250 .opt_str("remotePeerId", target.remote_peer_id.as_deref())
251 .into_pairs();
252 let path = format!("/v2/whep/sessions/{}", escape(&session_id));
253 self.client
254 .none(Method::DELETE, &path, CallOptions::new().query(query))
255 .await
256 }
257}
258
259#[derive(Debug, Clone, Serialize)]
263#[serde(rename_all = "camelCase")]
264pub struct SocketIngressAgent {
265 pub id: String,
267 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
269 pub kind: Option<String>,
270 #[serde(skip_serializing_if = "Option::is_none")]
272 pub metadata: Option<Map<String, Value>>,
273}
274
275#[derive(Debug, Clone, Default)]
277pub struct CreateSocketIngressParams {
278 pub participant_id: Option<String>,
280 pub name: Option<String>,
282 pub metadata: Option<Map<String, Value>>,
284 pub agent: Option<SocketIngressAgent>,
286 pub region: Option<String>,
288}
289
290#[derive(Debug, Serialize)]
291#[serde(rename_all = "camelCase")]
292struct SocketIngressParticipant {
293 #[serde(skip_serializing_if = "Option::is_none")]
294 id: Option<String>,
295 #[serde(skip_serializing_if = "Option::is_none")]
296 name: Option<String>,
297 #[serde(skip_serializing_if = "Option::is_none")]
298 metadata: Option<Map<String, Value>>,
299}
300
301#[derive(Debug, Serialize)]
302#[serde(rename_all = "camelCase")]
303struct SocketIngressWire<'a> {
304 room_id: &'a str,
305 #[serde(skip_serializing_if = "Option::is_none")]
306 participant: Option<SocketIngressParticipant>,
307 #[serde(skip_serializing_if = "Option::is_none")]
308 agent: Option<SocketIngressAgent>,
309 #[serde(skip_serializing_if = "Option::is_none")]
310 region: Option<String>,
311}
312
313#[derive(Debug, Clone, Deserialize)]
315#[serde(rename_all = "camelCase")]
316pub struct SocketIngress {
317 pub ws_url: String,
320 #[serde(default, deserialize_with = "crate::common::null_to_default")]
322 pub room_id: String,
323 #[serde(default, deserialize_with = "crate::common::null_to_default")]
325 pub expires_in: u32,
326 #[serde(skip)]
328 pub ws_ref: Option<String>,
329 #[serde(flatten)]
331 pub extra: Map<String, Value>,
332}
333
334fn extract_ws_ref(ws_url: &str) -> Option<String> {
336 let url = Url::parse(ws_url).ok()?;
337 url.query_pairs()
338 .find(|(name, _)| name == "ref")
339 .map(|(_, value)| value.into_owned())
340}
341
342#[derive(Debug, Clone, Copy)]
346pub struct SocketIngressResource<'a> {
347 client: &'a Client,
348}
349
350impl<'a> SocketIngressResource<'a> {
351 pub(crate) fn new(client: &'a Client) -> Self {
352 Self { client }
353 }
354
355 pub async fn create(
357 &self,
358 room_id: &str,
359 params: CreateSocketIngressParams,
360 ) -> Result<SocketIngress> {
361 let has_participant =
362 params.participant_id.is_some() || params.name.is_some() || params.metadata.is_some();
363 let participant = if has_participant {
364 Some(SocketIngressParticipant {
365 id: params.participant_id,
366 name: params.name,
367 metadata: params.metadata,
368 })
369 } else {
370 None
371 };
372
373 let body = SocketIngressWire {
374 room_id,
375 participant,
376 agent: params.agent,
377 region: params.region,
378 };
379
380 let mut ingress: SocketIngress = self
381 .client
382 .data(
383 Method::POST,
384 "/v2/ingest/sessions",
385 CallOptions::json(&body)?,
386 )
387 .await?;
388 ingress.ws_ref = extract_ws_ref(&ingress.ws_url);
389 Ok(ingress)
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn ingress_urls_omit_empty_parameters() {
399 let url = build_ingress_url(
400 "https://api.videosdk.live/",
401 "/v2/whip",
402 &[
403 ("roomId", "r-1"),
404 ("displayName", ""),
405 ("useExistingPeer", ""),
406 ],
407 )
408 .unwrap();
409 assert_eq!(url, "https://api.videosdk.live/v2/whip?roomId=r-1");
410 }
411
412 #[test]
413 fn ingress_urls_have_no_trailing_question_mark_when_empty() {
414 let url = build_ingress_url("https://api.videosdk.live", "/v2/whip", &[("a", "")]).unwrap();
415 assert_eq!(url, "https://api.videosdk.live/v2/whip");
416 }
417
418 #[test]
419 fn ingress_urls_percent_encode_their_values() {
420 let url =
421 build_ingress_url("https://x.test", "/v2/whep", &[("displayName", "Ada L")]).unwrap();
422 assert!(url.contains("displayName=Ada+L") || url.contains("displayName=Ada%20L"));
423 }
424
425 #[test]
426 fn the_ws_ref_is_read_out_of_the_url() {
427 assert_eq!(
428 extract_ws_ref("wss://x.test/ingest?ref=abc123&other=1").as_deref(),
429 Some("abc123")
430 );
431 assert_eq!(extract_ws_ref("wss://x.test/ingest"), None);
432 assert_eq!(extract_ws_ref("not a url"), None);
433 }
434}