Skip to main content

videocall_meeting_client/
lib.rs

1/*
2 * Copyright 2025 Security Union LLC
3 *
4 * Licensed under either of
5 *
6 * * Apache License, Version 2.0
7 *   (http://www.apache.org/licenses/LICENSE-2.0)
8 * * MIT license
9 *   (http://opensource.org/licenses/MIT)
10 *
11 * at your option.
12 */
13
14//! Cross-platform REST client for the videocall.rs meeting API.
15//!
16//! Works on WASM (browser), desktop, and mobile targets via [`reqwest`].
17//!
18//! # Example
19//!
20//! ```no_run
21//! use videocall_meeting_client::{MeetingApiClient, AuthMode};
22//!
23//! # async fn example() -> Result<(), videocall_meeting_client::ApiError> {
24//! // Browser: cookies are sent automatically
25//! let client = MeetingApiClient::new("http://localhost:8081", AuthMode::Cookie);
26//!
27//! // Native / mobile: use a bearer token
28//! let client = MeetingApiClient::new(
29//!     "http://localhost:8081",
30//!     AuthMode::Bearer("eyJ...".to_string()),
31//! );
32//!
33//! let profile = client.get_profile().await?;
34//! println!("Logged in as: {}", profile.email);
35//! # Ok(())
36//! # }
37//! ```
38
39pub mod auth;
40pub mod error;
41pub mod meetings;
42pub mod participants;
43pub mod waiting_room;
44
45pub use error::ApiError;
46pub use videocall_meeting_types;
47
48use reqwest::Client;
49
50/// How the client authenticates with the meeting API.
51#[derive(Debug, Clone)]
52pub enum AuthMode {
53    /// Browser mode: send credentials (cookies) automatically via `fetch`.
54    /// This is the mode used by `yew-ui` and other WASM frontends.
55    Cookie,
56    /// Bearer token mode: attach `Authorization: Bearer <token>` to every
57    /// request. Used by CLI tools, mobile apps, and integration tests.
58    Bearer(String),
59}
60
61/// A typed REST client for the videocall.rs meeting API.
62///
63/// All methods return strongly-typed responses from
64/// [`videocall_meeting_types`] and map HTTP errors to [`ApiError`].
65#[derive(Debug, Clone)]
66pub struct MeetingApiClient {
67    base_url: String,
68    auth: AuthMode,
69    http: Client,
70}
71
72impl MeetingApiClient {
73    /// Create a new client pointing at the given meeting-api base URL.
74    ///
75    /// # Arguments
76    ///
77    /// * `base_url` - e.g. `"http://localhost:8081"`
78    /// * `auth` - how to authenticate requests
79    pub fn new(base_url: &str, auth: AuthMode) -> Self {
80        Self {
81            base_url: base_url.trim_end_matches('/').to_string(),
82            auth,
83            http: Client::new(),
84        }
85    }
86
87    /// Update the bearer token (e.g. after a token refresh).
88    /// No-op if the client is in cookie mode.
89    pub fn set_bearer_token(&mut self, token: String) {
90        self.auth = AuthMode::Bearer(token);
91    }
92
93    /// Build a GET request with auth applied.
94    pub(crate) fn get(&self, path: &str) -> reqwest::RequestBuilder {
95        self.apply_auth(self.http.get(self.url(path)))
96    }
97
98    /// Build a POST request with auth applied.
99    pub(crate) fn post(&self, path: &str) -> reqwest::RequestBuilder {
100        self.apply_auth(self.http.post(self.url(path)))
101    }
102
103    /// Build a DELETE request with auth applied.
104    pub(crate) fn delete(&self, path: &str) -> reqwest::RequestBuilder {
105        self.apply_auth(self.http.delete(self.url(path)))
106    }
107
108    fn url(&self, path: &str) -> String {
109        format!("{}{}", self.base_url, path)
110    }
111
112    fn apply_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
113        match &self.auth {
114            AuthMode::Cookie => {
115                #[cfg(target_arch = "wasm32")]
116                {
117                    builder.fetch_credentials_include()
118                }
119                #[cfg(not(target_arch = "wasm32"))]
120                {
121                    builder
122                }
123            }
124            AuthMode::Bearer(token) => {
125                builder.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
126            }
127        }
128    }
129}
130
131/// Parse a standard `APIResponse<T>` body, returning `T` on success or
132/// mapping the error to [`ApiError`].
133pub(crate) async fn parse_api_response<T: serde::de::DeserializeOwned + serde::Serialize>(
134    response: reqwest::Response,
135) -> Result<T, ApiError> {
136    let status = response.status().as_u16();
137    match status {
138        200 | 201 => {
139            let wrapper: videocall_meeting_types::responses::APIResponse<T> =
140                response.json().await?;
141            Ok(wrapper.result)
142        }
143        401 => Err(ApiError::NotAuthenticated),
144        403 => {
145            let text = response.text().await.unwrap_or_default();
146            Err(ApiError::Forbidden(text))
147        }
148        404 => {
149            let text = response.text().await.unwrap_or_default();
150            Err(ApiError::NotFound(text))
151        }
152        400 => {
153            let text = response.text().await.unwrap_or_default();
154            if text.contains("MEETING_NOT_ACTIVE") {
155                Err(ApiError::MeetingNotActive)
156            } else {
157                Err(ApiError::ServerError {
158                    status: 400,
159                    body: text,
160                })
161            }
162        }
163        _ => {
164            let text = response.text().await.unwrap_or_default();
165            Err(ApiError::ServerError { status, body: text })
166        }
167    }
168}
169
170/// Parse a response where we only care about the status code, not the body.
171pub(crate) async fn parse_status_only(response: reqwest::Response) -> Result<(), ApiError> {
172    let status = response.status().as_u16();
173    match status {
174        200..=299 => Ok(()),
175        401 => Err(ApiError::NotAuthenticated),
176        403 => {
177            let text = response.text().await.unwrap_or_default();
178            Err(ApiError::Forbidden(text))
179        }
180        404 => {
181            let text = response.text().await.unwrap_or_default();
182            Err(ApiError::NotFound(text))
183        }
184        _ => {
185            let text = response.text().await.unwrap_or_default();
186            Err(ApiError::ServerError { status, body: text })
187        }
188    }
189}