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::*;
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        let normalized = token.trim();
41        if normalized.is_empty() {
42            self.auth_token = None;
43            return;
44        }
45        self.auth_token = Some(normalized.to_string());
46    }
47
48    pub fn auth_token(&self) -> Option<&str> {
49        self.auth_token.as_deref()
50    }
51
52    pub fn base_url(&self) -> &str {
53        &self.base_url
54    }
55
56    /// Access the underlying `reqwest::Client`.
57    pub fn reqwest_client(&self) -> &reqwest::Client {
58        &self.client
59    }
60
61    fn url(&self, path: &str) -> String {
62        format!("{}/api{}", self.base_url, path)
63    }
64
65    fn token_or_bail(&self) -> Result<&str> {
66        self.auth_token
67            .as_deref()
68            .ok_or_else(|| anyhow::anyhow!("auth token not set"))
69    }
70
71    // ── Health ────────────────────────────────────────────────────────────
72
73    pub async fn health(&self) -> Result<HealthResponse> {
74        let resp = self.client.get(self.url("/health")).send().await?;
75        parse_response(resp).await
76    }
77
78    // ── Auth ──────────────────────────────────────────────────────────────
79
80    pub async fn login(&self, req: &LoginRequest) -> Result<AuthTokenResponse> {
81        let resp = self
82            .client
83            .post(self.url("/auth/login"))
84            .json(req)
85            .send()
86            .await?;
87        parse_response(resp).await
88    }
89
90    pub async fn register(&self, req: &AuthRegisterRequest) -> Result<AuthTokenResponse> {
91        let resp = self
92            .client
93            .post(self.url("/auth/register"))
94            .json(req)
95            .send()
96            .await?;
97        parse_response(resp).await
98    }
99
100    pub async fn verify(&self) -> Result<VerifyResponse> {
101        let token = self.token_or_bail()?;
102        let resp = self
103            .client
104            .post(self.url("/auth/verify"))
105            .bearer_auth(token)
106            .send()
107            .await?;
108        parse_response(resp).await
109    }
110
111    pub async fn me(&self) -> Result<UserSettingsResponse> {
112        let token = self.token_or_bail()?;
113        let resp = self
114            .client
115            .get(self.url("/auth/me"))
116            .bearer_auth(token)
117            .send()
118            .await?;
119        parse_response(resp).await
120    }
121
122    pub async fn refresh(&self, req: &RefreshRequest) -> Result<AuthTokenResponse> {
123        let resp = self
124            .client
125            .post(self.url("/auth/refresh"))
126            .json(req)
127            .send()
128            .await?;
129        parse_response(resp).await
130    }
131
132    pub async fn logout(&self, req: &LogoutRequest) -> Result<OkResponse> {
133        let token = self.token_or_bail()?;
134        let resp = self
135            .client
136            .post(self.url("/auth/logout"))
137            .bearer_auth(token)
138            .json(req)
139            .send()
140            .await?;
141        parse_response(resp).await
142    }
143
144    pub async fn change_password(&self, req: &ChangePasswordRequest) -> Result<OkResponse> {
145        let token = self.token_or_bail()?;
146        let resp = self
147            .client
148            .post(self.url("/auth/change-password"))
149            .bearer_auth(token)
150            .json(req)
151            .send()
152            .await?;
153        parse_response(resp).await
154    }
155
156    pub async fn issue_api_key(&self) -> Result<IssueApiKeyResponse> {
157        let token = self.token_or_bail()?;
158        let resp = self
159            .client
160            .post(self.url("/auth/api-keys/issue"))
161            .bearer_auth(token)
162            .send()
163            .await?;
164        parse_response(resp).await
165    }
166
167    // ── Sessions ──────────────────────────────────────────────────────────
168
169    pub async fn upload_session(&self, req: &UploadRequest) -> Result<UploadResponse> {
170        let token = self.token_or_bail()?;
171        let resp = self
172            .client
173            .post(self.url("/sessions"))
174            .bearer_auth(token)
175            .json(req)
176            .send()
177            .await?;
178        parse_response(resp).await
179    }
180
181    pub async fn list_sessions(&self, query: &SessionListQuery) -> Result<SessionListResponse> {
182        let token = self.token_or_bail()?;
183        let mut url = self.url("/sessions");
184
185        // Build query string from the struct fields
186        let mut params = Vec::new();
187        params.push(format!("page={}", query.page));
188        params.push(format!("per_page={}", query.per_page));
189        if let Some(ref s) = query.search {
190            params.push(format!("search={s}"));
191        }
192        if let Some(ref t) = query.tool {
193            params.push(format!("tool={t}"));
194        }
195        if let Some(ref s) = query.sort {
196            params.push(format!("sort={s}"));
197        }
198        if let Some(ref r) = query.time_range {
199            params.push(format!("time_range={r}"));
200        }
201        if !params.is_empty() {
202            url = format!("{}?{}", url, params.join("&"));
203        }
204
205        let resp = self.client.get(&url).bearer_auth(token).send().await?;
206        parse_response(resp).await
207    }
208
209    pub async fn get_session(&self, id: &str) -> Result<SessionDetail> {
210        let token = self.token_or_bail()?;
211        let resp = self
212            .client
213            .get(self.url(&format!("/sessions/{id}")))
214            .bearer_auth(token)
215            .send()
216            .await?;
217        parse_response(resp).await
218    }
219
220    pub async fn delete_session(&self, id: &str) -> Result<OkResponse> {
221        let token = self.token_or_bail()?;
222        let resp = self
223            .client
224            .delete(self.url(&format!("/sessions/{id}")))
225            .bearer_auth(token)
226            .send()
227            .await?;
228        parse_response(resp).await
229    }
230
231    pub async fn get_session_raw(&self, id: &str) -> Result<serde_json::Value> {
232        let token = self.token_or_bail()?;
233        let resp = self
234            .client
235            .get(self.url(&format!("/sessions/{id}/raw")))
236            .bearer_auth(token)
237            .send()
238            .await?;
239        parse_response(resp).await
240    }
241
242    // ── Raw helpers (for E2E / advanced usage) ────────────────────────────
243
244    /// Authenticated GET returning the raw response.
245    pub async fn get_with_auth(&self, path: &str, token: &str) -> Result<reqwest::Response> {
246        Ok(self
247            .client
248            .get(self.url(path))
249            .bearer_auth(token)
250            .send()
251            .await?)
252    }
253
254    /// Authenticated POST (no body) returning the raw response.
255    pub async fn post_with_auth(&self, path: &str, token: &str) -> Result<reqwest::Response> {
256        Ok(self
257            .client
258            .post(self.url(path))
259            .bearer_auth(token)
260            .send()
261            .await?)
262    }
263
264    /// Authenticated POST with JSON body returning the raw response.
265    pub async fn post_json_with_auth<T: Serialize>(
266        &self,
267        path: &str,
268        token: &str,
269        body: &T,
270    ) -> Result<reqwest::Response> {
271        Ok(self
272            .client
273            .post(self.url(path))
274            .bearer_auth(token)
275            .json(body)
276            .send()
277            .await?)
278    }
279
280    /// Authenticated PUT with JSON body returning the raw response.
281    pub async fn put_json_with_auth<T: Serialize>(
282        &self,
283        path: &str,
284        token: &str,
285        body: &T,
286    ) -> Result<reqwest::Response> {
287        Ok(self
288            .client
289            .put(self.url(path))
290            .bearer_auth(token)
291            .json(body)
292            .send()
293            .await?)
294    }
295
296    /// Authenticated DELETE returning the raw response.
297    pub async fn delete_with_auth(&self, path: &str, token: &str) -> Result<reqwest::Response> {
298        Ok(self
299            .client
300            .delete(self.url(path))
301            .bearer_auth(token)
302            .send()
303            .await?)
304    }
305
306    /// Unauthenticated POST with JSON body returning the raw response.
307    pub async fn post_json_raw<T: Serialize>(
308        &self,
309        path: &str,
310        body: &T,
311    ) -> Result<reqwest::Response> {
312        Ok(self.client.post(self.url(path)).json(body).send().await?)
313    }
314}
315
316/// Parse an HTTP response: return the deserialized body on 2xx,
317/// or an error containing the status and body text.
318async fn parse_response<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Result<T> {
319    let status = resp.status();
320    if !status.is_success() {
321        let body = resp.text().await.unwrap_or_default();
322        bail!("{status}: {body}");
323    }
324    Ok(resp.json().await?)
325}
326
327#[cfg(test)]
328mod tests {
329    use super::ApiClient;
330    use std::time::Duration;
331
332    #[test]
333    fn set_auth_trims_surrounding_whitespace() {
334        let mut client = ApiClient::new("https://example.com", Duration::from_secs(1))
335            .expect("client should construct");
336
337        client.set_auth("  osk_test_token  ".to_string());
338        assert_eq!(client.auth_token(), Some("osk_test_token"));
339    }
340
341    #[test]
342    fn set_auth_clears_auth_for_blank_tokens() {
343        let mut client = ApiClient::new("https://example.com", Duration::from_secs(1))
344            .expect("client should construct");
345
346        client.set_auth("osk_test_token".to_string());
347        assert_eq!(client.auth_token(), Some("osk_test_token"));
348
349        client.set_auth("   ".to_string());
350        assert_eq!(client.auth_token(), None);
351    }
352}