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 #[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 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, ¶ms).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 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", ¶ms).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 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 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, ¶ms).await
320 }
321
322 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 let encoded_id = meeting_id.replace('/', "%2F");
339 self.get(&format!("/meetings/{encoded_id}/recordings"))
340 .await
341 }
342
343 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 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"), ¶ms)
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}