use std::time::Duration;
use reqwest::{Method, Url};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::client::{CallOptions, Client};
use crate::error::{Error, Result};
use crate::query::QueryBuilder;
use crate::resources::escape;
use crate::resources::random_uuid;
use crate::resources::session_utils::resolve_active_session_id;
fn build_ingress_url(base: &str, path: &str, params: &[(&str, &str)]) -> Result<String> {
let mut url = Url::parse(&format!("{}{path}", base.trim_end_matches('/')))
.map_err(|e| Error::config(format!("invalid base URL {base:?}: {e}")))?;
let pairs: Vec<_> = params
.iter()
.filter(|(_, value)| !value.is_empty())
.collect();
if !pairs.is_empty() {
let mut query = url.query_pairs_mut();
for (name, value) in pairs {
query.append_pair(name, value);
}
}
Ok(url.into())
}
#[derive(Debug, Clone, Default)]
pub struct CreateWhipParams {
pub participant_id: Option<String>,
pub name: Option<String>,
pub expires_in: Option<Duration>,
pub use_existing_peer: Option<bool>,
}
#[derive(Debug, Clone)]
pub struct WhipIngress {
pub url: String,
pub token: String,
pub stream_key: String,
pub room_id: String,
pub participant_id: String,
pub display_name: Option<String>,
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Copy)]
pub struct WhipResource<'a> {
client: &'a Client,
}
impl<'a> WhipResource<'a> {
pub(crate) fn new(client: &'a Client) -> Self {
Self { client }
}
pub fn create(&self, room_id: &str, params: CreateWhipParams) -> Result<WhipIngress> {
let participant_id = params
.participant_id
.unwrap_or_else(|| format!("whip-{}", random_uuid()));
let token = self
.client
.mint_api_token(params.expires_in.unwrap_or_default())?;
let url = build_ingress_url(
self.client.base_url(),
"/v2/whip",
&[
("roomId", room_id),
("participantId", &participant_id),
("displayName", params.name.as_deref().unwrap_or("")),
(
"useExistingPeer",
if params.use_existing_peer == Some(true) {
"true"
} else {
""
},
),
],
)?;
Ok(WhipIngress {
url,
stream_key: token.clone(),
token,
room_id: room_id.to_string(),
participant_id,
display_name: params.name,
session_id: None,
})
}
pub async fn delete(&self, target: &WhipIngress) -> Result<()> {
let session_id = match &target.session_id {
Some(session_id) => session_id.clone(),
None => resolve_active_session_id(self.client, &target.room_id)
.await?
.ok_or_else(|| {
Error::not_found(format!("no active session for room {}", target.room_id))
})?,
};
let query = QueryBuilder::new()
.str_val("participantId", &target.participant_id)
.opt_str("displayName", target.display_name.as_deref())
.into_pairs();
let path = format!("/v2/whip/sessions/{}", escape(&session_id));
self.client
.none(Method::DELETE, &path, CallOptions::new().query(query))
.await
}
}
#[derive(Debug, Clone)]
pub struct WhepSource {
pub participant_id: String,
}
#[derive(Debug, Clone, Default)]
pub struct CreateWhepParams {
pub participant_id: Option<String>,
pub source: Option<WhepSource>,
pub expires_in: Option<Duration>,
}
#[derive(Debug, Clone)]
pub struct WhepPlayback {
pub url: String,
pub token: String,
pub room_id: String,
pub participant_id: String,
pub remote_peer_id: Option<String>,
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Copy)]
pub struct WhepResource<'a> {
client: &'a Client,
}
impl<'a> WhepResource<'a> {
pub(crate) fn new(client: &'a Client) -> Self {
Self { client }
}
pub fn create(&self, room_id: &str, params: CreateWhepParams) -> Result<WhepPlayback> {
let participant_id = params
.participant_id
.unwrap_or_else(|| format!("whep-{}", random_uuid()));
let remote_peer_id = params.source.map(|source| source.participant_id);
let token = self
.client
.mint_api_token(params.expires_in.unwrap_or_default())?;
let url = build_ingress_url(
self.client.base_url(),
"/v2/whep",
&[
("roomId", room_id),
("participantId", &participant_id),
("remotePeerId", remote_peer_id.as_deref().unwrap_or("")),
],
)?;
Ok(WhepPlayback {
url,
token,
room_id: room_id.to_string(),
participant_id,
remote_peer_id,
session_id: None,
})
}
pub async fn delete(&self, target: &WhepPlayback) -> Result<()> {
let session_id = match &target.session_id {
Some(session_id) => session_id.clone(),
None => resolve_active_session_id(self.client, &target.room_id)
.await?
.ok_or_else(|| {
Error::not_found(format!("no active session for room {}", target.room_id))
})?,
};
let query = QueryBuilder::new()
.str_val("participantId", &target.participant_id)
.opt_str("remotePeerId", target.remote_peer_id.as_deref())
.into_pairs();
let path = format!("/v2/whep/sessions/{}", escape(&session_id));
self.client
.none(Method::DELETE, &path, CallOptions::new().query(query))
.await
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SocketIngressAgent {
pub id: String,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Map<String, Value>>,
}
#[derive(Debug, Clone, Default)]
pub struct CreateSocketIngressParams {
pub participant_id: Option<String>,
pub name: Option<String>,
pub metadata: Option<Map<String, Value>>,
pub agent: Option<SocketIngressAgent>,
pub region: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct SocketIngressParticipant {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<Map<String, Value>>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct SocketIngressWire<'a> {
room_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
participant: Option<SocketIngressParticipant>,
#[serde(skip_serializing_if = "Option::is_none")]
agent: Option<SocketIngressAgent>,
#[serde(skip_serializing_if = "Option::is_none")]
region: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SocketIngress {
pub ws_url: String,
#[serde(default, deserialize_with = "crate::common::null_to_default")]
pub room_id: String,
#[serde(default, deserialize_with = "crate::common::null_to_default")]
pub expires_in: u32,
#[serde(skip)]
pub ws_ref: Option<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
fn extract_ws_ref(ws_url: &str) -> Option<String> {
let url = Url::parse(ws_url).ok()?;
url.query_pairs()
.find(|(name, _)| name == "ref")
.map(|(_, value)| value.into_owned())
}
#[derive(Debug, Clone, Copy)]
pub struct SocketIngressResource<'a> {
client: &'a Client,
}
impl<'a> SocketIngressResource<'a> {
pub(crate) fn new(client: &'a Client) -> Self {
Self { client }
}
pub async fn create(
&self,
room_id: &str,
params: CreateSocketIngressParams,
) -> Result<SocketIngress> {
let has_participant =
params.participant_id.is_some() || params.name.is_some() || params.metadata.is_some();
let participant = if has_participant {
Some(SocketIngressParticipant {
id: params.participant_id,
name: params.name,
metadata: params.metadata,
})
} else {
None
};
let body = SocketIngressWire {
room_id,
participant,
agent: params.agent,
region: params.region,
};
let mut ingress: SocketIngress = self
.client
.data(
Method::POST,
"/v2/ingest/sessions",
CallOptions::json(&body)?,
)
.await?;
ingress.ws_ref = extract_ws_ref(&ingress.ws_url);
Ok(ingress)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ingress_urls_omit_empty_parameters() {
let url = build_ingress_url(
"https://api.videosdk.live/",
"/v2/whip",
&[
("roomId", "r-1"),
("displayName", ""),
("useExistingPeer", ""),
],
)
.unwrap();
assert_eq!(url, "https://api.videosdk.live/v2/whip?roomId=r-1");
}
#[test]
fn ingress_urls_have_no_trailing_question_mark_when_empty() {
let url = build_ingress_url("https://api.videosdk.live", "/v2/whip", &[("a", "")]).unwrap();
assert_eq!(url, "https://api.videosdk.live/v2/whip");
}
#[test]
fn ingress_urls_percent_encode_their_values() {
let url =
build_ingress_url("https://x.test", "/v2/whep", &[("displayName", "Ada L")]).unwrap();
assert!(url.contains("displayName=Ada+L") || url.contains("displayName=Ada%20L"));
}
#[test]
fn the_ws_ref_is_read_out_of_the_url() {
assert_eq!(
extract_ws_ref("wss://x.test/ingest?ref=abc123&other=1").as_deref(),
Some("abc123")
);
assert_eq!(extract_ws_ref("wss://x.test/ingest"), None);
assert_eq!(extract_ws_ref("not a url"), None);
}
}