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 list_sessions(&self, query: &SessionListQuery) -> Result<SessionListResponse> {
170        let token = self.token_or_bail()?;
171        let mut url = self.url("/sessions");
172
173        // Build query string from the struct fields
174        let mut params = Vec::new();
175        params.push(format!("page={}", query.page));
176        params.push(format!("per_page={}", query.per_page));
177        if let Some(ref s) = query.search {
178            params.push(format!("search={s}"));
179        }
180        if let Some(ref t) = query.tool {
181            params.push(format!("tool={t}"));
182        }
183        if let Some(ref s) = query.sort {
184            params.push(format!("sort={s}"));
185        }
186        if let Some(ref r) = query.time_range {
187            params.push(format!("time_range={r}"));
188        }
189        if !params.is_empty() {
190            url = format!("{}?{}", url, params.join("&"));
191        }
192
193        let resp = self.client.get(&url).bearer_auth(token).send().await?;
194        parse_response(resp).await
195    }
196
197    pub async fn get_session(&self, id: &str) -> Result<SessionDetail> {
198        let token = self.token_or_bail()?;
199        let resp = self
200            .client
201            .get(self.url(&format!("/sessions/{id}")))
202            .bearer_auth(token)
203            .send()
204            .await?;
205        parse_response(resp).await
206    }
207
208    pub async fn delete_session(&self, id: &str) -> Result<OkResponse> {
209        let token = self.token_or_bail()?;
210        let resp = self
211            .client
212            .delete(self.url(&format!("/sessions/{id}")))
213            .bearer_auth(token)
214            .send()
215            .await?;
216        parse_response(resp).await
217    }
218
219    pub async fn get_session_raw(&self, id: &str) -> Result<serde_json::Value> {
220        let token = self.token_or_bail()?;
221        let resp = self
222            .client
223            .get(self.url(&format!("/sessions/{id}/raw")))
224            .bearer_auth(token)
225            .send()
226            .await?;
227        parse_response(resp).await
228    }
229
230    // ── Raw helpers (for E2E / advanced usage) ────────────────────────────
231
232    /// Authenticated GET returning the raw response.
233    pub async fn get_with_auth(&self, path: &str, token: &str) -> Result<reqwest::Response> {
234        Ok(self
235            .client
236            .get(self.url(path))
237            .bearer_auth(token)
238            .send()
239            .await?)
240    }
241
242    /// Authenticated POST (no body) returning the raw response.
243    pub async fn post_with_auth(&self, path: &str, token: &str) -> Result<reqwest::Response> {
244        Ok(self
245            .client
246            .post(self.url(path))
247            .bearer_auth(token)
248            .send()
249            .await?)
250    }
251
252    /// Authenticated POST with JSON body returning the raw response.
253    pub async fn post_json_with_auth<T: Serialize>(
254        &self,
255        path: &str,
256        token: &str,
257        body: &T,
258    ) -> Result<reqwest::Response> {
259        Ok(self
260            .client
261            .post(self.url(path))
262            .bearer_auth(token)
263            .json(body)
264            .send()
265            .await?)
266    }
267
268    /// Authenticated PUT with JSON body returning the raw response.
269    pub async fn put_json_with_auth<T: Serialize>(
270        &self,
271        path: &str,
272        token: &str,
273        body: &T,
274    ) -> Result<reqwest::Response> {
275        Ok(self
276            .client
277            .put(self.url(path))
278            .bearer_auth(token)
279            .json(body)
280            .send()
281            .await?)
282    }
283
284    /// Authenticated DELETE returning the raw response.
285    pub async fn delete_with_auth(&self, path: &str, token: &str) -> Result<reqwest::Response> {
286        Ok(self
287            .client
288            .delete(self.url(path))
289            .bearer_auth(token)
290            .send()
291            .await?)
292    }
293
294    /// Unauthenticated POST with JSON body returning the raw response.
295    pub async fn post_json_raw<T: Serialize>(
296        &self,
297        path: &str,
298        body: &T,
299    ) -> Result<reqwest::Response> {
300        Ok(self.client.post(self.url(path)).json(body).send().await?)
301    }
302}
303
304/// Parse an HTTP response: return the deserialized body on 2xx,
305/// or an error containing the status and body text.
306async fn parse_response<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Result<T> {
307    let status = resp.status();
308    if !status.is_success() {
309        let body = resp.text().await.unwrap_or_default();
310        bail!("{status}: {body}");
311    }
312    Ok(resp.json().await?)
313}
314
315#[cfg(test)]
316mod tests {
317    use super::ApiClient;
318    use std::time::Duration;
319
320    #[test]
321    fn set_auth_trims_surrounding_whitespace() {
322        let mut client = ApiClient::new("https://example.com", Duration::from_secs(1))
323            .expect("client should construct");
324
325        client.set_auth("  osk_test_token  ".to_string());
326        assert_eq!(client.auth_token(), Some("osk_test_token"));
327    }
328
329    #[test]
330    fn set_auth_clears_auth_for_blank_tokens() {
331        let mut client = ApiClient::new("https://example.com", Duration::from_secs(1))
332            .expect("client should construct");
333
334        client.set_auth("osk_test_token".to_string());
335        assert_eq!(client.auth_token(), Some("osk_test_token"));
336
337        client.set_auth("   ".to_string());
338        assert_eq!(client.auth_token(), None);
339    }
340}