Skip to main content

rainy_sdk/
session.rs

1use crate::error::{ApiErrorResponse, RainyError, Result};
2use reqwest::{
3    header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE, USER_AGENT},
4    Client, Method, Response,
5};
6use serde::{de::DeserializeOwned, Deserialize, Serialize};
7
8#[derive(Debug, Clone)]
9pub struct SessionConfig {
10    pub base_url: String,
11    pub timeout_seconds: u64,
12    pub user_agent: String,
13}
14
15impl Default for SessionConfig {
16    fn default() -> Self {
17        Self {
18            base_url: crate::DEFAULT_BASE_URL.to_string(),
19            timeout_seconds: 30,
20            user_agent: format!("rainy-sdk/{}/session", crate::VERSION),
21        }
22    }
23}
24
25impl SessionConfig {
26    pub fn new() -> Self {
27        Self::default()
28    }
29
30    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
31        self.base_url = base_url.into();
32        self
33    }
34
35    pub fn with_timeout(mut self, timeout_seconds: u64) -> Self {
36        self.timeout_seconds = timeout_seconds;
37        self
38    }
39
40    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
41        self.user_agent = user_agent.into();
42        self
43    }
44}
45
46#[derive(Debug, Clone)]
47pub struct RainySessionClient {
48    client: Client,
49    config: SessionConfig,
50    access_token: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize)]
54pub struct LoginRequest<'a> {
55    pub email: &'a str,
56    pub password: &'a str,
57}
58
59#[derive(Debug, Clone, Serialize)]
60pub struct RegisterRequest<'a> {
61    pub email: &'a str,
62    pub password: &'a str,
63    pub region: &'a str,
64}
65
66#[derive(Debug, Clone, Serialize)]
67pub struct RefreshRequest<'a> {
68    #[serde(rename = "refreshToken")]
69    pub refresh_token: &'a str,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct SessionUser {
74    pub id: String,
75    pub email: String,
76    pub role: String,
77    #[serde(rename = "orgId", default)]
78    pub org_id: Option<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct SessionTokens {
83    #[serde(rename = "accessToken")]
84    pub access_token: String,
85    #[serde(rename = "refreshToken")]
86    pub refresh_token: String,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct LoginResponse {
91    #[serde(rename = "accessToken")]
92    pub access_token: String,
93    #[serde(rename = "refreshToken")]
94    pub refresh_token: String,
95    pub user: SessionUser,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct RefreshResponse {
100    #[serde(rename = "accessToken")]
101    pub access_token: String,
102    #[serde(rename = "refreshToken")]
103    pub refresh_token: String,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct OrgProfile {
108    pub id: String,
109    pub name: String,
110    #[serde(rename = "planId")]
111    pub plan_id: String,
112    pub region: String,
113    #[serde(rename = "createdAt")]
114    pub created_at: String,
115    pub credits: String,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct SessionApiKeyListItem {
120    pub id: String,
121    pub name: String,
122    #[serde(default)]
123    pub r#type: Option<String>,
124    #[serde(rename = "isActive")]
125    pub is_active: bool,
126    #[serde(rename = "lastUsed", default)]
127    pub last_used: Option<String>,
128    #[serde(rename = "createdAt")]
129    pub created_at: String,
130    #[serde(default)]
131    pub prefix: Option<String>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct CreatedApiKey {
136    pub key: String,
137    pub id: String,
138    pub name: String,
139    pub r#type: String,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct UsageCreditsResponse {
144    pub balance: f64,
145    pub currency: String,
146    #[serde(default)]
147    pub source: Option<String>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct UsageStatsResponse {
152    #[serde(rename = "periodDays")]
153    pub period_days: u32,
154    #[serde(rename = "totalRequests")]
155    pub total_requests: u64,
156    #[serde(rename = "totalCreditsDeducted")]
157    pub total_credits_deducted: f64,
158    #[serde(rename = "statsByProvider", default)]
159    pub stats_by_provider: serde_json::Value,
160    #[serde(default)]
161    pub logs: Vec<serde_json::Value>,
162    #[serde(default)]
163    pub data: Option<serde_json::Value>,
164}
165
166#[derive(Debug, Deserialize)]
167struct ApiEnvelope<T> {
168    success: bool,
169    data: T,
170}
171
172#[derive(Debug, Deserialize)]
173struct ListKeysEnvelope {
174    success: bool,
175    keys: Vec<SessionApiKeyListItem>,
176}
177
178impl RainySessionClient {
179    pub fn new() -> Result<Self> {
180        Self::with_config(SessionConfig::default())
181    }
182
183    pub fn with_config(config: SessionConfig) -> Result<Self> {
184        if url::Url::parse(&config.base_url).is_err() {
185            return Err(RainyError::InvalidRequest {
186                code: "INVALID_BASE_URL".to_string(),
187                message: "Base URL is not a valid URL".to_string(),
188                details: None,
189            });
190        }
191
192        let mut headers = HeaderMap::new();
193        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
194        headers.insert(
195            USER_AGENT,
196            HeaderValue::from_str(&config.user_agent).map_err(|e| RainyError::Network {
197                message: format!("Invalid user agent: {e}"),
198                retryable: false,
199                source_error: Some(e.to_string()),
200            })?,
201        );
202
203        let client = Client::builder()
204            .use_rustls_tls()
205            .min_tls_version(reqwest::tls::Version::TLS_1_2)
206            .timeout(std::time::Duration::from_secs(config.timeout_seconds))
207            .default_headers(headers)
208            .build()
209            .map_err(|e| RainyError::Network {
210                message: format!("Failed to create HTTP client: {e}"),
211                retryable: false,
212                source_error: Some(e.to_string()),
213            })?;
214
215        Ok(Self {
216            client,
217            config,
218            access_token: None,
219        })
220    }
221
222    pub fn with_base_url(base_url: impl Into<String>) -> Result<Self> {
223        Self::with_config(SessionConfig::default().with_base_url(base_url))
224    }
225
226    pub fn set_access_token(&mut self, access_token: impl Into<String>) {
227        self.access_token = Some(access_token.into());
228    }
229
230    pub fn clear_access_token(&mut self) {
231        self.access_token = None;
232    }
233
234    pub fn access_token(&self) -> Option<&str> {
235        self.access_token.as_deref()
236    }
237
238    pub fn base_url(&self) -> &str {
239        &self.config.base_url
240    }
241
242    fn api_v1_url(&self, path: &str) -> String {
243        let normalized = if path.starts_with('/') {
244            path.to_string()
245        } else {
246            format!("/{path}")
247        };
248        format!(
249            "{}/api/v1{}",
250            self.config.base_url.trim_end_matches('/'),
251            normalized
252        )
253    }
254
255    async fn parse_response<T: DeserializeOwned>(&self, response: Response) -> Result<T> {
256        let status = response.status();
257        let request_id = response
258            .headers()
259            .get("x-request-id")
260            .and_then(|v| v.to_str().ok())
261            .map(ToOwned::to_owned);
262        let text = response.text().await.unwrap_or_default();
263
264        if status.is_success() {
265            serde_json::from_str::<T>(&text).map_err(|e| RainyError::Serialization {
266                message: format!("Failed to parse response: {e}"),
267                source_error: Some(e.to_string()),
268            })
269        } else if let Ok(error_response) = serde_json::from_str::<ApiErrorResponse>(&text) {
270            let error = error_response.error;
271            Err(RainyError::Api {
272                code: error.code,
273                message: error.message,
274                status_code: status.as_u16(),
275                retryable: status.is_server_error(),
276                request_id,
277            })
278        } else {
279            Err(RainyError::Api {
280                code: status.canonical_reason().unwrap_or("UNKNOWN").to_string(),
281                message: if text.is_empty() {
282                    format!("HTTP {}", status.as_u16())
283                } else {
284                    text
285                },
286                status_code: status.as_u16(),
287                retryable: status.is_server_error(),
288                request_id,
289            })
290        }
291    }
292
293    async fn request_json<T: DeserializeOwned, B: Serialize>(
294        &self,
295        method: Method,
296        path: &str,
297        body: Option<&B>,
298        auth: bool,
299    ) -> Result<T> {
300        let mut request = self.client.request(method, self.api_v1_url(path));
301
302        if auth {
303            let token = self
304                .access_token
305                .as_ref()
306                .ok_or_else(|| RainyError::Authentication {
307                    code: "MISSING_SESSION_TOKEN".to_string(),
308                    message: "Session access token is required for this operation".to_string(),
309                    retryable: false,
310                })?;
311            request = request.header(AUTHORIZATION, format!("Bearer {token}"));
312        }
313
314        if let Some(body) = body {
315            request = request.json(body);
316        }
317
318        let response = request.send().await?;
319        self.parse_response(response).await
320    }
321
322    pub async fn login(&mut self, email: &str, password: &str) -> Result<LoginResponse> {
323        let response: LoginResponse = self
324            .request_json(
325                Method::POST,
326                "/auth/login",
327                Some(&LoginRequest { email, password }),
328                false,
329            )
330            .await?;
331        self.access_token = Some(response.access_token.clone());
332        Ok(response)
333    }
334
335    pub async fn register(
336        &mut self,
337        email: &str,
338        password: &str,
339        region: &str,
340    ) -> Result<LoginResponse> {
341        let response: LoginResponse = self
342            .request_json(
343                Method::POST,
344                "/auth/register",
345                Some(&RegisterRequest {
346                    email,
347                    password,
348                    region,
349                }),
350                false,
351            )
352            .await?;
353        self.access_token = Some(response.access_token.clone());
354        Ok(response)
355    }
356
357    pub async fn refresh(&mut self, refresh_token: &str) -> Result<RefreshResponse> {
358        let response: RefreshResponse = self
359            .request_json(
360                Method::POST,
361                "/auth/refresh",
362                Some(&RefreshRequest { refresh_token }),
363                false,
364            )
365            .await?;
366        self.access_token = Some(response.access_token.clone());
367        Ok(response)
368    }
369
370    pub async fn me(&self) -> Result<SessionUser> {
371        let envelope: ApiEnvelope<SessionUser> = self
372            .request_json::<ApiEnvelope<SessionUser>, serde_json::Value>(
373                Method::GET,
374                "/auth/me",
375                None,
376                true,
377            )
378            .await?;
379        let _ = envelope.success;
380        Ok(envelope.data)
381    }
382
383    pub async fn org_me(&self) -> Result<OrgProfile> {
384        let response: OrgProfile = self
385            .request_json(
386                Method::GET,
387                "/orgs/me",
388                Option::<&serde_json::Value>::None,
389                true,
390            )
391            .await?;
392        Ok(response)
393    }
394
395    pub async fn list_api_keys(&self) -> Result<Vec<SessionApiKeyListItem>> {
396        let response: ListKeysEnvelope = self
397            .request_json(
398                Method::GET,
399                "/keys",
400                Option::<&serde_json::Value>::None,
401                true,
402            )
403            .await?;
404        let _ = response.success;
405        Ok(response.keys)
406    }
407
408    pub async fn create_api_key(
409        &self,
410        name: &str,
411        key_type: Option<&str>,
412    ) -> Result<CreatedApiKey> {
413        #[derive(Serialize)]
414        struct CreateKeyRequest<'a> {
415            name: &'a str,
416            #[serde(skip_serializing_if = "Option::is_none")]
417            r#type: Option<&'a str>,
418        }
419        let response: CreatedApiKey = self
420            .request_json(
421                Method::POST,
422                "/keys",
423                Some(&CreateKeyRequest {
424                    name,
425                    r#type: key_type,
426                }),
427                true,
428            )
429            .await?;
430        Ok(response)
431    }
432
433    pub async fn delete_api_key(&self, id: &str) -> Result<serde_json::Value> {
434        self.request_json(
435            Method::DELETE,
436            &format!("/keys/{id}"),
437            Option::<&serde_json::Value>::None,
438            true,
439        )
440        .await
441    }
442
443    pub async fn usage_credits(&self) -> Result<UsageCreditsResponse> {
444        self.request_json(
445            Method::GET,
446            "/usage/credits",
447            Option::<&serde_json::Value>::None,
448            true,
449        )
450        .await
451    }
452
453    pub async fn usage_stats(&self, days: Option<u32>) -> Result<UsageStatsResponse> {
454        let path = match days {
455            Some(days) => format!("/usage/stats?days={days}"),
456            None => "/usage/stats".to_string(),
457        };
458        self.request_json(Method::GET, &path, Option::<&serde_json::Value>::None, true)
459            .await
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn session_client_uses_v3_base_url() {
469        let client = RainySessionClient::new().expect("session client");
470        assert!(client.base_url().starts_with("https://"));
471        assert_eq!(
472            client.api_v1_url("/auth/login"),
473            format!("{}/api/v1/auth/login", client.base_url())
474        );
475    }
476
477    #[test]
478    fn parses_login_alias_shape() {
479        let payload = r#"{
480          "success": true,
481          "data": {"accessToken":"a","refreshToken":"r","user":{"id":"1","email":"e@x.com","role":"admin"}},
482          "accessToken":"a",
483          "refreshToken":"r",
484          "user":{"id":"1","email":"e@x.com","role":"admin"}
485        }"#;
486        let parsed: LoginResponse = serde_json::from_str(payload).expect("deserialize login");
487        assert_eq!(parsed.access_token, "a");
488        assert_eq!(parsed.refresh_token, "r");
489        assert_eq!(parsed.user.email, "e@x.com");
490    }
491}