Skip to main content

opensession_api_client/
client.rs

1use std::time::Duration;
2
3use anyhow::{bail, Result};
4use serde::Serialize;
5
6use opensession_api_types::*;
7
8/// Typed HTTP client for the OpenSession API.
9///
10/// Provides high-level methods for each API endpoint (using the stored auth
11/// token) and low-level `*_with_auth` methods for callers that need per-request
12/// auth (e.g. E2E tests exercising multiple users).
13pub struct ApiClient {
14    client: reqwest::Client,
15    base_url: String,
16    auth_token: Option<String>,
17}
18
19impl ApiClient {
20    /// Create a new client with the given base URL and timeout.
21    pub fn new(base_url: &str, timeout: Duration) -> Result<Self> {
22        let client = reqwest::Client::builder().timeout(timeout).build()?;
23        Ok(Self {
24            client,
25            base_url: base_url.trim_end_matches('/').to_string(),
26            auth_token: None,
27        })
28    }
29
30    /// Create from an existing `reqwest::Client` (e.g. shared in tests).
31    pub fn with_client(client: reqwest::Client, base_url: &str) -> Self {
32        Self {
33            client,
34            base_url: base_url.trim_end_matches('/').to_string(),
35            auth_token: None,
36        }
37    }
38
39    pub fn set_auth(&mut self, token: String) {
40        self.auth_token = Some(token);
41    }
42
43    pub fn auth_token(&self) -> Option<&str> {
44        self.auth_token.as_deref()
45    }
46
47    pub fn base_url(&self) -> &str {
48        &self.base_url
49    }
50
51    /// Access the underlying `reqwest::Client`.
52    pub fn reqwest_client(&self) -> &reqwest::Client {
53        &self.client
54    }
55
56    fn url(&self, path: &str) -> String {
57        format!("{}/api{}", self.base_url, path)
58    }
59
60    fn token_or_bail(&self) -> Result<&str> {
61        self.auth_token
62            .as_deref()
63            .ok_or_else(|| anyhow::anyhow!("auth token not set"))
64    }
65
66    // ── Health ────────────────────────────────────────────────────────────
67
68    pub async fn health(&self) -> Result<HealthResponse> {
69        let resp = self.client.get(self.url("/health")).send().await?;
70        parse_response(resp).await
71    }
72
73    // ── Auth ──────────────────────────────────────────────────────────────
74
75    pub async fn login(&self, req: &LoginRequest) -> Result<AuthTokenResponse> {
76        let resp = self
77            .client
78            .post(self.url("/auth/login"))
79            .json(req)
80            .send()
81            .await?;
82        parse_response(resp).await
83    }
84
85    pub async fn register(&self, req: &AuthRegisterRequest) -> Result<AuthTokenResponse> {
86        let resp = self
87            .client
88            .post(self.url("/auth/register"))
89            .json(req)
90            .send()
91            .await?;
92        parse_response(resp).await
93    }
94
95    pub async fn verify(&self) -> Result<VerifyResponse> {
96        let token = self.token_or_bail()?;
97        let resp = self
98            .client
99            .post(self.url("/auth/verify"))
100            .bearer_auth(token)
101            .send()
102            .await?;
103        parse_response(resp).await
104    }
105
106    pub async fn me(&self) -> Result<UserSettingsResponse> {
107        let token = self.token_or_bail()?;
108        let resp = self
109            .client
110            .get(self.url("/auth/me"))
111            .bearer_auth(token)
112            .send()
113            .await?;
114        parse_response(resp).await
115    }
116
117    pub async fn refresh(&self, req: &RefreshRequest) -> Result<AuthTokenResponse> {
118        let resp = self
119            .client
120            .post(self.url("/auth/refresh"))
121            .json(req)
122            .send()
123            .await?;
124        parse_response(resp).await
125    }
126
127    pub async fn logout(&self, req: &LogoutRequest) -> Result<OkResponse> {
128        let token = self.token_or_bail()?;
129        let resp = self
130            .client
131            .post(self.url("/auth/logout"))
132            .bearer_auth(token)
133            .json(req)
134            .send()
135            .await?;
136        parse_response(resp).await
137    }
138
139    pub async fn change_password(&self, req: &ChangePasswordRequest) -> Result<OkResponse> {
140        let token = self.token_or_bail()?;
141        let resp = self
142            .client
143            .post(self.url("/auth/change-password"))
144            .bearer_auth(token)
145            .json(req)
146            .send()
147            .await?;
148        parse_response(resp).await
149    }
150
151    pub async fn regenerate_key(&self) -> Result<RegenerateKeyResponse> {
152        let token = self.token_or_bail()?;
153        let resp = self
154            .client
155            .post(self.url("/auth/regenerate-key"))
156            .bearer_auth(token)
157            .send()
158            .await?;
159        parse_response(resp).await
160    }
161
162    // ── Sessions ──────────────────────────────────────────────────────────
163
164    pub async fn upload_session(&self, req: &UploadRequest) -> Result<UploadResponse> {
165        let token = self.token_or_bail()?;
166        let resp = self
167            .client
168            .post(self.url("/sessions"))
169            .bearer_auth(token)
170            .json(req)
171            .send()
172            .await?;
173        parse_response(resp).await
174    }
175
176    pub async fn list_sessions(&self, query: &SessionListQuery) -> Result<SessionListResponse> {
177        let token = self.token_or_bail()?;
178        let mut url = self.url("/sessions");
179
180        // Build query string from the struct fields
181        let mut params = Vec::new();
182        params.push(format!("page={}", query.page));
183        params.push(format!("per_page={}", query.per_page));
184        if let Some(ref s) = query.search {
185            params.push(format!("search={s}"));
186        }
187        if let Some(ref t) = query.tool {
188            params.push(format!("tool={t}"));
189        }
190        if let Some(ref t) = query.team_id {
191            params.push(format!("team_id={t}"));
192        }
193        if let Some(ref s) = query.sort {
194            params.push(format!("sort={s}"));
195        }
196        if let Some(ref r) = query.time_range {
197            params.push(format!("time_range={r}"));
198        }
199        if !params.is_empty() {
200            url = format!("{}?{}", url, params.join("&"));
201        }
202
203        let resp = self.client.get(&url).bearer_auth(token).send().await?;
204        parse_response(resp).await
205    }
206
207    pub async fn get_session(&self, id: &str) -> Result<SessionDetail> {
208        let token = self.token_or_bail()?;
209        let resp = self
210            .client
211            .get(self.url(&format!("/sessions/{id}")))
212            .bearer_auth(token)
213            .send()
214            .await?;
215        parse_response(resp).await
216    }
217
218    pub async fn delete_session(&self, id: &str) -> Result<OkResponse> {
219        let token = self.token_or_bail()?;
220        let resp = self
221            .client
222            .delete(self.url(&format!("/sessions/{id}")))
223            .bearer_auth(token)
224            .send()
225            .await?;
226        parse_response(resp).await
227    }
228
229    pub async fn get_session_raw(&self, id: &str) -> Result<serde_json::Value> {
230        let token = self.token_or_bail()?;
231        let resp = self
232            .client
233            .get(self.url(&format!("/sessions/{id}/raw")))
234            .bearer_auth(token)
235            .send()
236            .await?;
237        parse_response(resp).await
238    }
239
240    // ── Teams ─────────────────────────────────────────────────────────────
241
242    pub async fn list_teams(&self) -> Result<ListTeamsResponse> {
243        let token = self.token_or_bail()?;
244        let resp = self
245            .client
246            .get(self.url("/teams"))
247            .bearer_auth(token)
248            .send()
249            .await?;
250        parse_response(resp).await
251    }
252
253    pub async fn create_team(&self, req: &CreateTeamRequest) -> Result<TeamResponse> {
254        let token = self.token_or_bail()?;
255        let resp = self
256            .client
257            .post(self.url("/teams"))
258            .bearer_auth(token)
259            .json(req)
260            .send()
261            .await?;
262        parse_response(resp).await
263    }
264
265    pub async fn get_team(&self, id: &str) -> Result<TeamDetailResponse> {
266        let token = self.token_or_bail()?;
267        let resp = self
268            .client
269            .get(self.url(&format!("/teams/{id}")))
270            .bearer_auth(token)
271            .send()
272            .await?;
273        parse_response(resp).await
274    }
275
276    pub async fn update_team(&self, id: &str, req: &UpdateTeamRequest) -> Result<TeamResponse> {
277        let token = self.token_or_bail()?;
278        let resp = self
279            .client
280            .put(self.url(&format!("/teams/{id}")))
281            .bearer_auth(token)
282            .json(req)
283            .send()
284            .await?;
285        parse_response(resp).await
286    }
287
288    // ── Members ───────────────────────────────────────────────────────────
289
290    pub async fn add_member(&self, team_id: &str, req: &AddMemberRequest) -> Result<OkResponse> {
291        let token = self.token_or_bail()?;
292        let resp = self
293            .client
294            .post(self.url(&format!("/teams/{team_id}/members")))
295            .bearer_auth(token)
296            .json(req)
297            .send()
298            .await?;
299        parse_response(resp).await
300    }
301
302    pub async fn list_members(&self, team_id: &str) -> Result<ListMembersResponse> {
303        let token = self.token_or_bail()?;
304        let resp = self
305            .client
306            .get(self.url(&format!("/teams/{team_id}/members")))
307            .bearer_auth(token)
308            .send()
309            .await?;
310        parse_response(resp).await
311    }
312
313    pub async fn remove_member(&self, team_id: &str, user_id: &str) -> Result<OkResponse> {
314        let token = self.token_or_bail()?;
315        let resp = self
316            .client
317            .delete(self.url(&format!("/teams/{team_id}/members/{user_id}")))
318            .bearer_auth(token)
319            .send()
320            .await?;
321        parse_response(resp).await
322    }
323
324    // ── Invitations ─────────────────────────────────────────────────────────
325
326    pub async fn list_invitations(&self) -> Result<ListInvitationsResponse> {
327        let token = self.token_or_bail()?;
328        let resp = self
329            .client
330            .get(self.url("/invitations"))
331            .bearer_auth(token)
332            .send()
333            .await?;
334        parse_response(resp).await
335    }
336
337    pub async fn accept_invitation(&self, id: &str) -> Result<AcceptInvitationResponse> {
338        let token = self.token_or_bail()?;
339        let resp = self
340            .client
341            .post(self.url(&format!("/invitations/{id}/accept")))
342            .bearer_auth(token)
343            .send()
344            .await?;
345        parse_response(resp).await
346    }
347
348    pub async fn decline_invitation(&self, id: &str) -> Result<OkResponse> {
349        let token = self.token_or_bail()?;
350        let resp = self
351            .client
352            .post(self.url(&format!("/invitations/{id}/decline")))
353            .bearer_auth(token)
354            .send()
355            .await?;
356        parse_response(resp).await
357    }
358
359    pub async fn invite_member(&self, team_id: &str, req: &InviteRequest) -> Result<OkResponse> {
360        let token = self.token_or_bail()?;
361        let resp = self
362            .client
363            .post(self.url(&format!("/teams/{team_id}/invite")))
364            .bearer_auth(token)
365            .json(req)
366            .send()
367            .await?;
368        parse_response(resp).await
369    }
370
371    // ── Sync ──────────────────────────────────────────────────────────────
372
373    pub async fn sync_pull(
374        &self,
375        team_id: &str,
376        since: Option<&str>,
377        limit: Option<u32>,
378    ) -> Result<SyncPullResponse> {
379        let token = self.token_or_bail()?;
380        let mut url = format!("{}?team_id={team_id}", self.url("/sync/pull"));
381        if let Some(since) = since {
382            url.push_str(&format!("&since={since}"));
383        }
384        if let Some(limit) = limit {
385            url.push_str(&format!("&limit={limit}"));
386        }
387        let resp = self.client.get(&url).bearer_auth(token).send().await?;
388        parse_response(resp).await
389    }
390
391    // ── Config Sync ───────────────────────────────────────────────────────
392
393    pub async fn config_sync(&self, team_id: &str) -> Result<ConfigSyncResponse> {
394        let token = self.token_or_bail()?;
395        let resp = self
396            .client
397            .get(self.url(&format!("/teams/{team_id}/config")))
398            .bearer_auth(token)
399            .send()
400            .await?;
401        parse_response(resp).await
402    }
403
404    // ── Raw helpers (for E2E / advanced usage) ────────────────────────────
405
406    /// Authenticated GET returning the raw response.
407    pub async fn get_with_auth(&self, path: &str, token: &str) -> Result<reqwest::Response> {
408        Ok(self
409            .client
410            .get(self.url(path))
411            .bearer_auth(token)
412            .send()
413            .await?)
414    }
415
416    /// Authenticated POST (no body) returning the raw response.
417    pub async fn post_with_auth(&self, path: &str, token: &str) -> Result<reqwest::Response> {
418        Ok(self
419            .client
420            .post(self.url(path))
421            .bearer_auth(token)
422            .send()
423            .await?)
424    }
425
426    /// Authenticated POST with JSON body returning the raw response.
427    pub async fn post_json_with_auth<T: Serialize>(
428        &self,
429        path: &str,
430        token: &str,
431        body: &T,
432    ) -> Result<reqwest::Response> {
433        Ok(self
434            .client
435            .post(self.url(path))
436            .bearer_auth(token)
437            .json(body)
438            .send()
439            .await?)
440    }
441
442    /// Authenticated PUT with JSON body returning the raw response.
443    pub async fn put_json_with_auth<T: Serialize>(
444        &self,
445        path: &str,
446        token: &str,
447        body: &T,
448    ) -> Result<reqwest::Response> {
449        Ok(self
450            .client
451            .put(self.url(path))
452            .bearer_auth(token)
453            .json(body)
454            .send()
455            .await?)
456    }
457
458    /// Authenticated DELETE returning the raw response.
459    pub async fn delete_with_auth(&self, path: &str, token: &str) -> Result<reqwest::Response> {
460        Ok(self
461            .client
462            .delete(self.url(path))
463            .bearer_auth(token)
464            .send()
465            .await?)
466    }
467
468    /// Unauthenticated POST with JSON body returning the raw response.
469    pub async fn post_json_raw<T: Serialize>(
470        &self,
471        path: &str,
472        body: &T,
473    ) -> Result<reqwest::Response> {
474        Ok(self.client.post(self.url(path)).json(body).send().await?)
475    }
476}
477
478/// Parse an HTTP response: return the deserialized body on 2xx,
479/// or an error containing the status and body text.
480async fn parse_response<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Result<T> {
481    let status = resp.status();
482    if !status.is_success() {
483        let body = resp.text().await.unwrap_or_default();
484        bail!("{status}: {body}");
485    }
486    Ok(resp.json().await?)
487}