Skip to main content

zoom_cli/api/
client.rs

1use base64::Engine;
2use base64::engine::general_purpose::STANDARD as BASE64;
3use serde::Serialize;
4use serde::de::DeserializeOwned;
5
6use super::ApiError;
7use super::types::*;
8
9const ZOOM_API_BASE: &str = "https://api.zoom.us/v2";
10const ZOOM_OAUTH_BASE: &str = "https://zoom.us";
11
12pub struct ZoomClient {
13    http: reqwest::Client,
14    base_url: String,
15    oauth_base_url: String,
16    account_id: String,
17    client_id: String,
18    client_secret: String,
19    token: Option<String>,
20}
21
22impl ZoomClient {
23    pub fn new(account_id: String, client_id: String, client_secret: String) -> Self {
24        let http = reqwest::Client::builder()
25            .timeout(std::time::Duration::from_secs(30))
26            .build()
27            .expect("failed to build HTTP client");
28        Self {
29            http,
30            base_url: ZOOM_API_BASE.to_owned(),
31            oauth_base_url: ZOOM_OAUTH_BASE.to_owned(),
32            account_id,
33            client_id,
34            client_secret,
35            token: None,
36        }
37    }
38
39    /// For tests: skip OAuth flow, use a pre-provided token and a mock base URL.
40    #[cfg(test)]
41    pub fn new_for_test(base_url: String, oauth_base_url: String, token: String) -> Self {
42        let http = reqwest::Client::builder()
43            .timeout(std::time::Duration::from_secs(5))
44            .build()
45            .expect("failed to build HTTP client");
46        Self {
47            http,
48            base_url,
49            oauth_base_url,
50            account_id: "test-account".into(),
51            client_id: "test-client".into(),
52            client_secret: "test-secret".into(),
53            token: Some(token),
54        }
55    }
56
57    async fn ensure_token(&mut self) -> Result<&str, ApiError> {
58        if self.token.is_none() {
59            let token = self.fetch_token().await?;
60            self.token = Some(token);
61        }
62        Ok(self.token.as_deref().unwrap())
63    }
64
65    async fn fetch_token(&self) -> Result<String, ApiError> {
66        let creds = BASE64.encode(format!("{}:{}", self.client_id, self.client_secret));
67        let url = format!(
68            "{}/oauth/token?grant_type=account_credentials&account_id={}",
69            self.oauth_base_url, self.account_id
70        );
71        let resp = self
72            .http
73            .post(&url)
74            .header("Authorization", format!("Basic {creds}"))
75            .header("Content-Type", "application/x-www-form-urlencoded")
76            .send()
77            .await?;
78
79        let status = resp.status();
80        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
81            return Err(ApiError::Auth(
82                "Failed to obtain access token. Check account_id, client_id, client_secret.".into(),
83            ));
84        }
85        if !status.is_success() {
86            let body = resp.text().await.unwrap_or_default();
87            return Err(ApiError::Api {
88                status: status.as_u16(),
89                message: body,
90            });
91        }
92
93        let token_resp: TokenResponse = resp.json().await?;
94        Ok(token_resp.access_token)
95    }
96
97    async fn get<T: DeserializeOwned>(&mut self, path: &str) -> Result<T, ApiError> {
98        let url = format!("{}{}", self.base_url, path);
99        let token = self.ensure_token().await?.to_owned();
100        let resp = self.http.get(&url).bearer_auth(&token).send().await?;
101        self.handle_response(resp).await
102    }
103
104    async fn get_with_query<T: DeserializeOwned>(
105        &mut self,
106        path: &str,
107        params: &[(&str, &str)],
108    ) -> Result<T, ApiError> {
109        let url = format!("{}{}", self.base_url, path);
110        let token = self.ensure_token().await?.to_owned();
111        let resp = self
112            .http
113            .get(&url)
114            .bearer_auth(&token)
115            .query(params)
116            .send()
117            .await?;
118        self.handle_response(resp).await
119    }
120
121    async fn post<T: DeserializeOwned, B: Serialize>(
122        &mut self,
123        path: &str,
124        body: &B,
125    ) -> Result<T, ApiError> {
126        let url = format!("{}{}", self.base_url, path);
127        let token = self.ensure_token().await?.to_owned();
128        let resp = self
129            .http
130            .post(&url)
131            .bearer_auth(&token)
132            .json(body)
133            .send()
134            .await?;
135        self.handle_response(resp).await
136    }
137
138    async fn patch<B: Serialize>(&mut self, path: &str, body: &B) -> Result<(), ApiError> {
139        let url = format!("{}{}", self.base_url, path);
140        let token = self.ensure_token().await?.to_owned();
141        let resp = self
142            .http
143            .patch(&url)
144            .bearer_auth(&token)
145            .json(body)
146            .send()
147            .await?;
148        self.handle_empty_response(resp).await
149    }
150
151    async fn put<B: Serialize>(&mut self, path: &str, body: &B) -> Result<(), ApiError> {
152        let url = format!("{}{}", self.base_url, path);
153        let token = self.ensure_token().await?.to_owned();
154        let resp = self
155            .http
156            .put(&url)
157            .bearer_auth(&token)
158            .json(body)
159            .send()
160            .await?;
161        self.handle_empty_response(resp).await
162    }
163
164    async fn delete(&mut self, path: &str) -> Result<(), ApiError> {
165        let url = format!("{}{}", self.base_url, path);
166        let token = self.ensure_token().await?.to_owned();
167        let resp = self.http.delete(&url).bearer_auth(&token).send().await?;
168        self.handle_empty_response(resp).await
169    }
170
171    async fn handle_response<T: DeserializeOwned>(
172        &self,
173        resp: reqwest::Response,
174    ) -> Result<T, ApiError> {
175        let status = resp.status();
176        match status.as_u16() {
177            200..=299 => Ok(resp.json::<T>().await?),
178            401 | 403 => {
179                let body = resp.text().await.unwrap_or_default();
180                Err(ApiError::Auth(body))
181            }
182            404 => {
183                let body = resp.text().await.unwrap_or_default();
184                Err(ApiError::NotFound(body))
185            }
186            429 => Err(ApiError::RateLimit),
187            _ => {
188                let body = resp.text().await.unwrap_or_default();
189                Err(ApiError::Api {
190                    status: status.as_u16(),
191                    message: body,
192                })
193            }
194        }
195    }
196
197    async fn handle_empty_response(&self, resp: reqwest::Response) -> Result<(), ApiError> {
198        let status = resp.status();
199        match status.as_u16() {
200            200..=299 => Ok(()),
201            401 | 403 => {
202                let body = resp.text().await.unwrap_or_default();
203                Err(ApiError::Auth(body))
204            }
205            404 => {
206                let body = resp.text().await.unwrap_or_default();
207                Err(ApiError::NotFound(body))
208            }
209            429 => Err(ApiError::RateLimit),
210            _ => {
211                let body = resp.text().await.unwrap_or_default();
212                Err(ApiError::Api {
213                    status: status.as_u16(),
214                    message: body,
215                })
216            }
217        }
218    }
219
220    // ── Meetings ──────────────────────────────────────────────────────────────
221
222    pub async fn list_meetings(
223        &mut self,
224        user_id: &str,
225        meeting_type: Option<&str>,
226    ) -> Result<MeetingList, ApiError> {
227        let path = format!("/users/{user_id}/meetings");
228        let mut params: Vec<(&str, &str)> = vec![("page_size", "100")];
229        let mt_owned;
230        if let Some(mt) = meeting_type {
231            mt_owned = mt.to_owned();
232            params.push(("type", mt_owned.as_str()));
233        }
234        self.get_with_query(&path, &params).await
235    }
236
237    pub async fn get_meeting(&mut self, meeting_id: u64) -> Result<Meeting, ApiError> {
238        self.get(&format!("/meetings/{meeting_id}")).await
239    }
240
241    pub async fn create_meeting(
242        &mut self,
243        user_id: &str,
244        req: CreateMeetingRequest,
245    ) -> Result<Meeting, ApiError> {
246        self.post(&format!("/users/{user_id}/meetings"), &req).await
247    }
248
249    pub async fn update_meeting(
250        &mut self,
251        meeting_id: u64,
252        req: UpdateMeetingRequest,
253    ) -> Result<(), ApiError> {
254        self.patch(&format!("/meetings/{meeting_id}"), &req).await
255    }
256
257    pub async fn delete_meeting(&mut self, meeting_id: u64) -> Result<(), ApiError> {
258        self.delete(&format!("/meetings/{meeting_id}")).await
259    }
260
261    pub async fn end_meeting(&mut self, meeting_id: u64) -> Result<(), ApiError> {
262        self.put(
263            &format!("/meetings/{meeting_id}/status"),
264            &MeetingStatusRequest { action: "end".into() },
265        )
266        .await
267    }
268
269    // ── Users ─────────────────────────────────────────────────────────────────
270
271    pub async fn list_users(&mut self, status: Option<&str>) -> Result<UserList, ApiError> {
272        let mut params: Vec<(&str, &str)> = vec![("page_size", "300")];
273        let st_owned;
274        if let Some(st) = status {
275            st_owned = st.to_owned();
276            params.push(("status", st_owned.as_str()));
277        }
278        self.get_with_query("/users", &params).await
279    }
280
281    pub async fn get_user(&mut self, user_id: &str) -> Result<User, ApiError> {
282        self.get(&format!("/users/{user_id}")).await
283    }
284
285    // ── Participants ─────────────────────────────────────────────────────────────
286
287    pub async fn list_past_meeting_participants(
288        &mut self,
289        meeting_id: &str,
290    ) -> Result<ParticipantList, ApiError> {
291        let encoded_id = meeting_id.replace('/', "%2F");
292        self.get_with_query(
293            &format!("/past_meetings/{encoded_id}/participants"),
294            &[("page_size", "300")],
295        )
296        .await
297    }
298
299    // ── Recordings ───────────────────────────────────────────────────────────
300
301    pub async fn list_recordings(
302        &mut self,
303        user_id: &str,
304        from: Option<&str>,
305        to: Option<&str>,
306    ) -> Result<RecordingList, ApiError> {
307        let path = format!("/users/{user_id}/recordings");
308        let mut params: Vec<(&str, &str)> = vec![("page_size", "30")];
309        let from_owned;
310        let to_owned;
311        if let Some(f) = from {
312            from_owned = f.to_owned();
313            params.push(("from", from_owned.as_str()));
314        }
315        if let Some(t) = to {
316            to_owned = t.to_owned();
317            params.push(("to", to_owned.as_str()));
318        }
319        self.get_with_query(&path, &params).await
320    }
321
322    /// Control recording state for a live meeting (start/stop/pause/resume).
323    pub async fn control_recording(
324        &mut self,
325        meeting_id: u64,
326        action: &str,
327    ) -> Result<(), ApiError> {
328        let req = RecordingControlRequest {
329            action: action.to_owned(),
330        };
331        self.patch(&format!("/live_meetings/{meeting_id}/recordings"), &req)
332            .await
333    }
334
335    pub async fn get_recording(&mut self, meeting_id: &str) -> Result<CloudRecording, ApiError> {
336        // Zoom meeting UUIDs can contain '/' (base64 chars); encode it so the
337        // character is not interpreted as a path separator.
338        let encoded_id = meeting_id.replace('/', "%2F");
339        self.get(&format!("/meetings/{encoded_id}/recordings"))
340            .await
341    }
342
343    /// Download a recording file to disk. Handles Zoom's auth-required downloads.
344    pub async fn download_recording_file(
345        &mut self,
346        download_url: &str,
347        dest_path: &std::path::Path,
348    ) -> Result<u64, ApiError> {
349        use futures_util::StreamExt;
350        use tokio::io::AsyncWriteExt;
351
352        let token = self.ensure_token().await?.to_owned();
353        let resp = self
354            .http
355            .get(download_url)
356            .bearer_auth(&token)
357            .send()
358            .await?;
359
360        let status = resp.status();
361        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
362            return Err(ApiError::Auth(
363                "Not authorized to download this recording".into(),
364            ));
365        }
366        if !status.is_success() {
367            let body = resp.text().await.unwrap_or_default();
368            return Err(ApiError::Api {
369                status: status.as_u16(),
370                message: body,
371            });
372        }
373
374        let mut file = tokio::fs::File::create(dest_path).await.map_err(|e| {
375            ApiError::Other(format!("Cannot create file {}: {e}", dest_path.display()))
376        })?;
377
378        let mut bytes_written: u64 = 0;
379        let mut stream = resp.bytes_stream();
380        while let Some(chunk) = stream.next().await {
381            let chunk = chunk?;
382            file.write_all(&chunk)
383                .await
384                .map_err(|e| ApiError::Other(format!("Write error: {e}")))?;
385            bytes_written += chunk.len() as u64;
386        }
387        file.flush()
388            .await
389            .map_err(|e| ApiError::Other(format!("Flush error: {e}")))?;
390
391        Ok(bytes_written)
392    }
393
394    // ── Reports ───────────────────────────────────────────────────────────────
395
396    pub async fn list_user_meeting_reports(
397        &mut self,
398        user_id: &str,
399        from: &str,
400        to: Option<&str>,
401    ) -> Result<UserMeetingReportList, ApiError> {
402        let mut params: Vec<(&str, &str)> = vec![("from", from), ("page_size", "300")];
403        let to_owned;
404        if let Some(t) = to {
405            to_owned = t.to_owned();
406            params.push(("to", to_owned.as_str()));
407        }
408        self.get_with_query(&format!("/report/users/{user_id}/meetings"), &params)
409            .await
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use wiremock::matchers::{header, method, path, query_param};
417    use wiremock::{Mock, MockServer, ResponseTemplate};
418
419    async fn mock_client(server: &MockServer) -> ZoomClient {
420        ZoomClient::new_for_test(
421            format!("{}/v2", server.uri()),
422            server.uri(),
423            "test-token".into(),
424        )
425    }
426
427    #[tokio::test]
428    async fn fetch_token_returns_access_token_on_success() {
429        let server = MockServer::start().await;
430
431        Mock::given(method("POST"))
432            .and(path("/oauth/token"))
433            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
434                "access_token": "eyJhbGciOiJSUzI1NiJ9.test",
435                "token_type": "bearer",
436                "expires_in": 3599
437            })))
438            .mount(&server)
439            .await;
440
441        let client = ZoomClient {
442            http: reqwest::Client::new(),
443            base_url: format!("{}/v2", server.uri()),
444            oauth_base_url: server.uri(),
445            account_id: "acct123".into(),
446            client_id: "cid".into(),
447            client_secret: "csec".into(),
448            token: None,
449        };
450
451        let token = client.fetch_token().await.unwrap();
452        assert_eq!(token, "eyJhbGciOiJSUzI1NiJ9.test");
453    }
454
455    #[tokio::test]
456    async fn fetch_token_returns_auth_error_on_401() {
457        let server = MockServer::start().await;
458
459        Mock::given(method("POST"))
460            .and(path("/oauth/token"))
461            .respond_with(ResponseTemplate::new(401))
462            .mount(&server)
463            .await;
464
465        let client = ZoomClient {
466            http: reqwest::Client::new(),
467            base_url: format!("{}/v2", server.uri()),
468            oauth_base_url: server.uri(),
469            account_id: "acct".into(),
470            client_id: "cid".into(),
471            client_secret: "csec".into(),
472            token: None,
473        };
474
475        let err = client.fetch_token().await.unwrap_err();
476        assert!(matches!(err, ApiError::Auth(_)));
477    }
478
479    #[tokio::test]
480    async fn list_meetings_returns_meeting_list() {
481        let server = MockServer::start().await;
482
483        Mock::given(method("GET"))
484            .and(path("/v2/users/me/meetings"))
485            .and(header("authorization", "Bearer test-token"))
486            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
487                "meetings": [
488                    {"id": 111111111, "topic": "Standup", "duration": 15}
489                ],
490                "total_records": 1,
491                "page_size": 100
492            })))
493            .mount(&server)
494            .await;
495
496        let mut client = mock_client(&server).await;
497        let list = client.list_meetings("me", None).await.unwrap();
498        assert_eq!(list.meetings.len(), 1);
499        assert_eq!(list.meetings[0].topic, "Standup");
500    }
501
502    #[tokio::test]
503    async fn get_meeting_returns_404_as_not_found() {
504        let server = MockServer::start().await;
505
506        Mock::given(method("GET"))
507            .and(path("/v2/meetings/999999999"))
508            .respond_with(ResponseTemplate::new(404).set_body_string("Meeting not found"))
509            .mount(&server)
510            .await;
511
512        let mut client = mock_client(&server).await;
513        let err = client.get_meeting(999999999).await.unwrap_err();
514        assert!(matches!(err, ApiError::NotFound(_)));
515    }
516
517    #[tokio::test]
518    async fn delete_meeting_returns_ok_on_204() {
519        let server = MockServer::start().await;
520
521        Mock::given(method("DELETE"))
522            .and(path("/v2/meetings/123456789"))
523            .respond_with(ResponseTemplate::new(204))
524            .mount(&server)
525            .await;
526
527        let mut client = mock_client(&server).await;
528        client.delete_meeting(123456789).await.unwrap();
529    }
530
531    #[tokio::test]
532    async fn list_meetings_with_type_filter_sends_query_param() {
533        let server = MockServer::start().await;
534
535        Mock::given(method("GET"))
536            .and(path("/v2/users/me/meetings"))
537            .and(query_param("type", "scheduled"))
538            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
539                "meetings": [],
540                "total_records": 0
541            })))
542            .mount(&server)
543            .await;
544
545        let mut client = mock_client(&server).await;
546        let list = client.list_meetings("me", Some("scheduled")).await.unwrap();
547        assert_eq!(list.meetings.len(), 0);
548    }
549
550    #[tokio::test]
551    async fn rate_limit_response_returns_rate_limit_error() {
552        let server = MockServer::start().await;
553
554        Mock::given(method("GET"))
555            .and(path("/v2/users/me/meetings"))
556            .respond_with(ResponseTemplate::new(429))
557            .mount(&server)
558            .await;
559
560        let mut client = mock_client(&server).await;
561        let err = client.list_meetings("me", None).await.unwrap_err();
562        assert!(matches!(err, ApiError::RateLimit));
563    }
564
565    #[tokio::test]
566    async fn list_users_sends_correct_request() {
567        let server = MockServer::start().await;
568
569        Mock::given(method("GET"))
570            .and(path("/v2/users"))
571            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
572                "users": [
573                    {
574                        "id": "user-123",
575                        "email": "alice@example.com",
576                        "display_name": "Alice"
577                    }
578                ],
579                "total_records": 1
580            })))
581            .mount(&server)
582            .await;
583
584        let mut client = mock_client(&server).await;
585        let list = client.list_users(None).await.unwrap();
586        assert_eq!(list.users.len(), 1);
587        assert_eq!(list.users[0].email, "alice@example.com");
588    }
589
590    #[tokio::test]
591    async fn end_meeting_sends_put_with_action() {
592        let server = MockServer::start().await;
593        Mock::given(method("PUT"))
594            .and(path("/v2/meetings/123456/status"))
595            .respond_with(ResponseTemplate::new(204))
596            .mount(&server)
597            .await;
598        let mut client = mock_client(&server).await;
599        client.end_meeting(123456).await.unwrap();
600    }
601
602    #[tokio::test]
603    async fn list_past_meeting_participants_returns_list() {
604        let server = MockServer::start().await;
605        Mock::given(method("GET"))
606            .and(path("/v2/past_meetings/abc123/participants"))
607            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
608                "participants": [
609                    {"name": "Alice", "user_email": "alice@example.com", "duration": 1800}
610                ],
611                "total_records": 1
612            })))
613            .mount(&server)
614            .await;
615        let mut client = mock_client(&server).await;
616        let list = client.list_past_meeting_participants("abc123").await.unwrap();
617        assert_eq!(list.participants.len(), 1);
618        assert_eq!(list.participants[0].name, Some("Alice".into()));
619    }
620
621    #[tokio::test]
622    async fn list_user_meeting_reports_sends_from_param() {
623        let server = MockServer::start().await;
624        Mock::given(method("GET"))
625            .and(path("/v2/report/users/me/meetings"))
626            .and(query_param("from", "2026-04-01"))
627            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
628                "meetings": [],
629                "total_records": 0,
630                "from": "2026-04-01",
631                "to": "2026-04-30"
632            })))
633            .mount(&server)
634            .await;
635        let mut client = mock_client(&server).await;
636        let list = client.list_user_meeting_reports("me", "2026-04-01", None).await.unwrap();
637        assert_eq!(list.meetings.len(), 0);
638    }
639}