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 get_session_raw(&self, id: &str) -> Result<serde_json::Value> {
219        let token = self.token_or_bail()?;
220        let resp = self
221            .client
222            .get(self.url(&format!("/sessions/{id}/raw")))
223            .bearer_auth(token)
224            .send()
225            .await?;
226        parse_response(resp).await
227    }
228
229    // ── Teams ─────────────────────────────────────────────────────────────
230
231    pub async fn list_teams(&self) -> Result<ListTeamsResponse> {
232        let token = self.token_or_bail()?;
233        let resp = self
234            .client
235            .get(self.url("/teams"))
236            .bearer_auth(token)
237            .send()
238            .await?;
239        parse_response(resp).await
240    }
241
242    pub async fn create_team(&self, req: &CreateTeamRequest) -> Result<TeamResponse> {
243        let token = self.token_or_bail()?;
244        let resp = self
245            .client
246            .post(self.url("/teams"))
247            .bearer_auth(token)
248            .json(req)
249            .send()
250            .await?;
251        parse_response(resp).await
252    }
253
254    pub async fn get_team(&self, id: &str) -> Result<TeamDetailResponse> {
255        let token = self.token_or_bail()?;
256        let resp = self
257            .client
258            .get(self.url(&format!("/teams/{id}")))
259            .bearer_auth(token)
260            .send()
261            .await?;
262        parse_response(resp).await
263    }
264
265    pub async fn update_team(&self, id: &str, req: &UpdateTeamRequest) -> Result<TeamResponse> {
266        let token = self.token_or_bail()?;
267        let resp = self
268            .client
269            .put(self.url(&format!("/teams/{id}")))
270            .bearer_auth(token)
271            .json(req)
272            .send()
273            .await?;
274        parse_response(resp).await
275    }
276
277    // ── Members ───────────────────────────────────────────────────────────
278
279    pub async fn add_member(&self, team_id: &str, req: &AddMemberRequest) -> Result<OkResponse> {
280        let token = self.token_or_bail()?;
281        let resp = self
282            .client
283            .post(self.url(&format!("/teams/{team_id}/members")))
284            .bearer_auth(token)
285            .json(req)
286            .send()
287            .await?;
288        parse_response(resp).await
289    }
290
291    pub async fn list_members(&self, team_id: &str) -> Result<ListMembersResponse> {
292        let token = self.token_or_bail()?;
293        let resp = self
294            .client
295            .get(self.url(&format!("/teams/{team_id}/members")))
296            .bearer_auth(token)
297            .send()
298            .await?;
299        parse_response(resp).await
300    }
301
302    pub async fn remove_member(&self, team_id: &str, user_id: &str) -> Result<OkResponse> {
303        let token = self.token_or_bail()?;
304        let resp = self
305            .client
306            .delete(self.url(&format!("/teams/{team_id}/members/{user_id}")))
307            .bearer_auth(token)
308            .send()
309            .await?;
310        parse_response(resp).await
311    }
312
313    // ── Invitations ─────────────────────────────────────────────────────────
314
315    pub async fn list_invitations(&self) -> Result<ListInvitationsResponse> {
316        let token = self.token_or_bail()?;
317        let resp = self
318            .client
319            .get(self.url("/invitations"))
320            .bearer_auth(token)
321            .send()
322            .await?;
323        parse_response(resp).await
324    }
325
326    pub async fn accept_invitation(&self, id: &str) -> Result<AcceptInvitationResponse> {
327        let token = self.token_or_bail()?;
328        let resp = self
329            .client
330            .post(self.url(&format!("/invitations/{id}/accept")))
331            .bearer_auth(token)
332            .send()
333            .await?;
334        parse_response(resp).await
335    }
336
337    pub async fn decline_invitation(&self, id: &str) -> Result<OkResponse> {
338        let token = self.token_or_bail()?;
339        let resp = self
340            .client
341            .post(self.url(&format!("/invitations/{id}/decline")))
342            .bearer_auth(token)
343            .send()
344            .await?;
345        parse_response(resp).await
346    }
347
348    pub async fn invite_member(&self, team_id: &str, req: &InviteRequest) -> Result<OkResponse> {
349        let token = self.token_or_bail()?;
350        let resp = self
351            .client
352            .post(self.url(&format!("/teams/{team_id}/invite")))
353            .bearer_auth(token)
354            .json(req)
355            .send()
356            .await?;
357        parse_response(resp).await
358    }
359
360    // ── Sync ──────────────────────────────────────────────────────────────
361
362    pub async fn sync_pull(
363        &self,
364        team_id: &str,
365        since: Option<&str>,
366        limit: Option<u32>,
367    ) -> Result<SyncPullResponse> {
368        let token = self.token_or_bail()?;
369        let mut url = format!("{}?team_id={team_id}", self.url("/sync/pull"));
370        if let Some(since) = since {
371            url.push_str(&format!("&since={since}"));
372        }
373        if let Some(limit) = limit {
374            url.push_str(&format!("&limit={limit}"));
375        }
376        let resp = self.client.get(&url).bearer_auth(token).send().await?;
377        parse_response(resp).await
378    }
379
380    // ── Streaming ─────────────────────────────────────────────────────────
381
382    pub async fn stream_events(
383        &self,
384        session_id: &str,
385        req: &serde_json::Value,
386    ) -> Result<reqwest::Response> {
387        let token = self.token_or_bail()?;
388        let resp = self
389            .client
390            .post(self.url(&format!("/sessions/{session_id}/events")))
391            .bearer_auth(token)
392            .json(req)
393            .send()
394            .await?;
395        Ok(resp)
396    }
397
398    // ── Config Sync ───────────────────────────────────────────────────────
399
400    pub async fn config_sync(&self, team_id: &str) -> Result<ConfigSyncResponse> {
401        let token = self.token_or_bail()?;
402        let resp = self
403            .client
404            .get(self.url(&format!("/teams/{team_id}/config")))
405            .bearer_auth(token)
406            .send()
407            .await?;
408        parse_response(resp).await
409    }
410
411    // ── Raw helpers (for E2E / advanced usage) ────────────────────────────
412
413    /// Authenticated GET returning the raw response.
414    pub async fn get_with_auth(&self, path: &str, token: &str) -> Result<reqwest::Response> {
415        Ok(self
416            .client
417            .get(self.url(path))
418            .bearer_auth(token)
419            .send()
420            .await?)
421    }
422
423    /// Authenticated POST (no body) returning the raw response.
424    pub async fn post_with_auth(&self, path: &str, token: &str) -> Result<reqwest::Response> {
425        Ok(self
426            .client
427            .post(self.url(path))
428            .bearer_auth(token)
429            .send()
430            .await?)
431    }
432
433    /// Authenticated POST with JSON body returning the raw response.
434    pub async fn post_json_with_auth<T: Serialize>(
435        &self,
436        path: &str,
437        token: &str,
438        body: &T,
439    ) -> Result<reqwest::Response> {
440        Ok(self
441            .client
442            .post(self.url(path))
443            .bearer_auth(token)
444            .json(body)
445            .send()
446            .await?)
447    }
448
449    /// Authenticated PUT with JSON body returning the raw response.
450    pub async fn put_json_with_auth<T: Serialize>(
451        &self,
452        path: &str,
453        token: &str,
454        body: &T,
455    ) -> Result<reqwest::Response> {
456        Ok(self
457            .client
458            .put(self.url(path))
459            .bearer_auth(token)
460            .json(body)
461            .send()
462            .await?)
463    }
464
465    /// Authenticated DELETE returning the raw response.
466    pub async fn delete_with_auth(&self, path: &str, token: &str) -> Result<reqwest::Response> {
467        Ok(self
468            .client
469            .delete(self.url(path))
470            .bearer_auth(token)
471            .send()
472            .await?)
473    }
474
475    /// Unauthenticated POST with JSON body returning the raw response.
476    pub async fn post_json_raw<T: Serialize>(
477        &self,
478        path: &str,
479        body: &T,
480    ) -> Result<reqwest::Response> {
481        Ok(self.client.post(self.url(path)).json(body).send().await?)
482    }
483}
484
485/// Parse an HTTP response: return the deserialized body on 2xx,
486/// or an error containing the status and body text.
487async fn parse_response<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Result<T> {
488    let status = resp.status();
489    if !status.is_success() {
490        let body = resp.text().await.unwrap_or_default();
491        bail!("{status}: {body}");
492    }
493    Ok(resp.json().await?)
494}