Skip to main content

rainy_sdk/
session.rs

1//! JWT/session client for Rainy API v3 dashboard-style endpoints.
2//!
3//! This module intentionally separates session/JWT operations from `RainyClient` (API-key flows)
4//! to keep trust boundaries clear and the default SDK surface smaller.
5
6use crate::error::{ApiErrorResponse, RainyError, Result};
7use reqwest::{
8    header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE, USER_AGENT},
9    Client, Method, Response,
10};
11use serde::{de::DeserializeOwned, Deserialize, Serialize};
12
13/// Configuration for [`RainySessionClient`].
14#[derive(Debug, Clone)]
15pub struct SessionConfig {
16    /// Base URL of the Rainy API v3 service (host only; API paths are added by the client).
17    pub base_url: String,
18    /// HTTP timeout in seconds for session requests.
19    pub timeout_seconds: u64,
20    /// User-Agent header used for session requests.
21    pub user_agent: String,
22}
23
24impl Default for SessionConfig {
25    fn default() -> Self {
26        Self {
27            base_url: crate::DEFAULT_BASE_URL.to_string(),
28            timeout_seconds: 30,
29            user_agent: format!("rainy-sdk/{}/session", crate::VERSION),
30        }
31    }
32}
33
34impl SessionConfig {
35    /// Creates a session configuration with sane defaults for Rainy API v3.
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Sets a custom base URL for the Rainy API service.
41    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
42        self.base_url = base_url.into();
43        self
44    }
45
46    /// Sets a custom request timeout (seconds).
47    pub fn with_timeout(mut self, timeout_seconds: u64) -> Self {
48        self.timeout_seconds = timeout_seconds;
49        self
50    }
51
52    /// Sets a custom User-Agent header value.
53    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
54        self.user_agent = user_agent.into();
55        self
56    }
57}
58
59/// Client for Rainy API v3 JWT/session endpoints.
60///
61/// Use this client for authentication and dashboard/account operations such as
62/// `/api/v1/auth/*`, `/api/v1/keys`, `/api/v1/usage/*`, and `/api/v1/orgs/me`.
63#[derive(Debug, Clone)]
64pub struct RainySessionClient {
65    client: Client,
66    config: SessionConfig,
67    access_token: Option<String>,
68}
69
70/// Request body for `POST /api/v1/auth/login`.
71#[derive(Debug, Clone, Serialize)]
72pub struct LoginRequest<'a> {
73    /// User email address.
74    pub email: &'a str,
75    /// User password.
76    pub password: &'a str,
77}
78
79/// Request body for `POST /api/v1/auth/register`.
80#[derive(Debug, Clone, Serialize)]
81pub struct RegisterRequest<'a> {
82    /// User email address.
83    pub email: &'a str,
84    /// User password.
85    pub password: &'a str,
86    /// Region code (for example `us` or `la`).
87    pub region: &'a str,
88}
89
90/// Request body for `POST /api/v1/auth/refresh`.
91#[derive(Debug, Clone, Serialize)]
92pub struct RefreshRequest<'a> {
93    /// Refresh token issued by the auth endpoints.
94    #[serde(rename = "refreshToken")]
95    pub refresh_token: &'a str,
96}
97
98/// Authenticated user profile returned by session endpoints.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SessionUser {
101    /// User identifier.
102    pub id: String,
103    /// User email.
104    pub email: String,
105    /// User role (`admin`, `member`, etc.).
106    pub role: String,
107    /// Organization identifier when included by the endpoint.
108    #[serde(rename = "orgId", default)]
109    pub org_id: Option<String>,
110}
111
112/// Pair of access and refresh tokens.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct SessionTokens {
115    /// Access token for authenticated session requests.
116    #[serde(rename = "accessToken")]
117    pub access_token: String,
118    /// Refresh token used to renew the access token.
119    #[serde(rename = "refreshToken")]
120    pub refresh_token: String,
121}
122
123/// Response payload for login/register auth endpoints.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct LoginResponse {
126    /// Access token returned by the API.
127    #[serde(rename = "accessToken")]
128    pub access_token: String,
129    /// Refresh token returned by the API.
130    #[serde(rename = "refreshToken")]
131    pub refresh_token: String,
132    /// Authenticated user information.
133    pub user: SessionUser,
134}
135
136/// Response payload for token refresh.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct RefreshResponse {
139    /// New access token.
140    #[serde(rename = "accessToken")]
141    pub access_token: String,
142    /// Rotated or reissued refresh token.
143    #[serde(rename = "refreshToken")]
144    pub refresh_token: String,
145}
146
147/// Organization profile returned by `GET /api/v1/orgs/me`.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct OrgProfile {
150    /// Organization identifier.
151    pub id: String,
152    /// Organization display name.
153    pub name: String,
154    /// Plan identifier (`payg`, `teams`, etc.).
155    #[serde(rename = "planId")]
156    pub plan_id: String,
157    /// Organization region code.
158    pub region: String,
159    /// ISO-8601 creation timestamp.
160    #[serde(rename = "createdAt")]
161    pub created_at: String,
162    /// Current credit balance as a string (server preserves precision).
163    pub credits: String,
164}
165
166/// API key summary item returned by `GET /api/v1/keys`.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct SessionApiKeyListItem {
169    /// API key identifier.
170    pub id: String,
171    /// User-defined key name.
172    pub name: String,
173    /// Key type if returned by the endpoint (`standard`, `platform`).
174    #[serde(default)]
175    pub r#type: Option<String>,
176    /// Whether the key is active.
177    #[serde(rename = "isActive")]
178    pub is_active: bool,
179    /// Last-used timestamp when available.
180    #[serde(rename = "lastUsed", default)]
181    pub last_used: Option<String>,
182    /// Creation timestamp.
183    #[serde(rename = "createdAt")]
184    pub created_at: String,
185    /// Masked prefix for display purposes.
186    #[serde(default)]
187    pub prefix: Option<String>,
188}
189
190/// Created API key response for `POST /api/v1/keys`.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct CreatedApiKey {
193    /// Plaintext API key value (returned only at creation time).
194    pub key: String,
195    /// Key identifier.
196    pub id: String,
197    /// Key display name.
198    pub name: String,
199    /// Key type (`standard` or `platform`).
200    pub r#type: String,
201}
202
203/// Credits balance response for `GET /api/v1/usage/credits`.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct UsageCreditsResponse {
206    /// Current credit balance.
207    pub balance: f64,
208    /// Currency unit (typically `credits`).
209    pub currency: String,
210    /// Source metadata alias returned by the server.
211    #[serde(default)]
212    pub source: Option<String>,
213}
214
215/// Usage statistics response for `GET /api/v1/usage/stats`.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct UsageStatsResponse {
218    /// Number of days included in the aggregation window.
219    #[serde(rename = "periodDays")]
220    pub period_days: u32,
221    /// Total requests in the selected period.
222    #[serde(rename = "totalRequests")]
223    pub total_requests: u64,
224    /// Total credits deducted in the selected period.
225    #[serde(rename = "totalCreditsDeducted")]
226    pub total_credits_deducted: f64,
227    /// Provider-level summary alias payload.
228    #[serde(rename = "statsByProvider", default)]
229    pub stats_by_provider: serde_json::Value,
230    /// Recent usage logs alias payload.
231    #[serde(default)]
232    pub logs: Vec<serde_json::Value>,
233    /// Canonical envelope `data` field when preserved by deserialization.
234    #[serde(default)]
235    pub data: Option<serde_json::Value>,
236}
237
238#[derive(Debug, Deserialize)]
239struct ApiEnvelope<T> {
240    success: bool,
241    data: T,
242}
243
244#[derive(Debug, Deserialize)]
245struct ListKeysEnvelope {
246    success: bool,
247    keys: Vec<SessionApiKeyListItem>,
248}
249
250impl RainySessionClient {
251    /// Creates a session client using default configuration.
252    pub fn new() -> Result<Self> {
253        Self::with_config(SessionConfig::default())
254    }
255
256    /// Creates a session client with custom configuration.
257    pub fn with_config(config: SessionConfig) -> Result<Self> {
258        if url::Url::parse(&config.base_url).is_err() {
259            return Err(RainyError::InvalidRequest {
260                code: "INVALID_BASE_URL".to_string(),
261                message: "Base URL is not a valid URL".to_string(),
262                details: None,
263            });
264        }
265
266        let mut headers = HeaderMap::new();
267        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
268        headers.insert(
269            USER_AGENT,
270            HeaderValue::from_str(&config.user_agent).map_err(|e| RainyError::Network {
271                message: format!("Invalid user agent: {e}"),
272                retryable: false,
273                source_error: Some(e.to_string()),
274            })?,
275        );
276
277        let client = Client::builder()
278            .use_rustls_tls()
279            .min_tls_version(reqwest::tls::Version::TLS_1_2)
280            .timeout(std::time::Duration::from_secs(config.timeout_seconds))
281            .default_headers(headers)
282            .build()
283            .map_err(|e| RainyError::Network {
284                message: format!("Failed to create HTTP client: {e}"),
285                retryable: false,
286                source_error: Some(e.to_string()),
287            })?;
288
289        Ok(Self {
290            client,
291            config,
292            access_token: None,
293        })
294    }
295
296    /// Creates a session client with only a custom base URL override.
297    pub fn with_base_url(base_url: impl Into<String>) -> Result<Self> {
298        Self::with_config(SessionConfig::default().with_base_url(base_url))
299    }
300
301    /// Sets the in-memory access token used for authenticated requests.
302    pub fn set_access_token(&mut self, access_token: impl Into<String>) {
303        self.access_token = Some(access_token.into());
304    }
305
306    /// Clears the in-memory access token.
307    pub fn clear_access_token(&mut self) {
308        self.access_token = None;
309    }
310
311    /// Returns the current in-memory access token, if set.
312    pub fn access_token(&self) -> Option<&str> {
313        self.access_token.as_deref()
314    }
315
316    /// Returns the configured API base URL.
317    pub fn base_url(&self) -> &str {
318        &self.config.base_url
319    }
320
321    fn api_v1_url(&self, path: &str) -> String {
322        let normalized = if path.starts_with('/') {
323            path.to_string()
324        } else {
325            format!("/{path}")
326        };
327        format!(
328            "{}/api/v1{}",
329            self.config.base_url.trim_end_matches('/'),
330            normalized
331        )
332    }
333
334    async fn parse_response<T: DeserializeOwned>(&self, response: Response) -> Result<T> {
335        let status = response.status();
336        let request_id = response
337            .headers()
338            .get("x-request-id")
339            .and_then(|v| v.to_str().ok())
340            .map(ToOwned::to_owned);
341        let text = response.text().await.unwrap_or_default();
342
343        if status.is_success() {
344            serde_json::from_str::<T>(&text).map_err(|e| RainyError::Serialization {
345                message: format!("Failed to parse response: {e}"),
346                source_error: Some(e.to_string()),
347            })
348        } else if let Ok(error_response) = serde_json::from_str::<ApiErrorResponse>(&text) {
349            let error = error_response.error;
350            Err(RainyError::Api {
351                code: error.code,
352                message: error.message,
353                status_code: status.as_u16(),
354                retryable: status.is_server_error(),
355                request_id,
356            })
357        } else {
358            Err(RainyError::Api {
359                code: status.canonical_reason().unwrap_or("UNKNOWN").to_string(),
360                message: if text.is_empty() {
361                    format!("HTTP {}", status.as_u16())
362                } else {
363                    text
364                },
365                status_code: status.as_u16(),
366                retryable: status.is_server_error(),
367                request_id,
368            })
369        }
370    }
371
372    async fn request_json<T: DeserializeOwned, B: Serialize>(
373        &self,
374        method: Method,
375        path: &str,
376        body: Option<&B>,
377        auth: bool,
378    ) -> Result<T> {
379        let mut request = self.client.request(method, self.api_v1_url(path));
380
381        if auth {
382            let token = self
383                .access_token
384                .as_ref()
385                .ok_or_else(|| RainyError::Authentication {
386                    code: "MISSING_SESSION_TOKEN".to_string(),
387                    message: "Session access token is required for this operation".to_string(),
388                    retryable: false,
389                })?;
390            request = request.header(AUTHORIZATION, format!("Bearer {token}"));
391        }
392
393        if let Some(body) = body {
394            request = request.json(body);
395        }
396
397        let response = request.send().await?;
398        self.parse_response(response).await
399    }
400
401    /// Authenticates a user and stores the returned access token in the client.
402    pub async fn login(&mut self, email: &str, password: &str) -> Result<LoginResponse> {
403        let response: LoginResponse = self
404            .request_json(
405                Method::POST,
406                "/auth/login",
407                Some(&LoginRequest { email, password }),
408                false,
409            )
410            .await?;
411        self.access_token = Some(response.access_token.clone());
412        Ok(response)
413    }
414
415    /// Registers a user and stores the returned access token in the client.
416    pub async fn register(
417        &mut self,
418        email: &str,
419        password: &str,
420        region: &str,
421    ) -> Result<LoginResponse> {
422        let response: LoginResponse = self
423            .request_json(
424                Method::POST,
425                "/auth/register",
426                Some(&RegisterRequest {
427                    email,
428                    password,
429                    region,
430                }),
431                false,
432            )
433            .await?;
434        self.access_token = Some(response.access_token.clone());
435        Ok(response)
436    }
437
438    /// Refreshes the session token pair and stores the new access token.
439    pub async fn refresh(&mut self, refresh_token: &str) -> Result<RefreshResponse> {
440        let response: RefreshResponse = self
441            .request_json(
442                Method::POST,
443                "/auth/refresh",
444                Some(&RefreshRequest { refresh_token }),
445                false,
446            )
447            .await?;
448        self.access_token = Some(response.access_token.clone());
449        Ok(response)
450    }
451
452    /// Returns the current authenticated user profile from `GET /api/v1/auth/me`.
453    pub async fn me(&self) -> Result<SessionUser> {
454        let envelope: ApiEnvelope<SessionUser> = self
455            .request_json::<ApiEnvelope<SessionUser>, serde_json::Value>(
456                Method::GET,
457                "/auth/me",
458                None,
459                true,
460            )
461            .await?;
462        let _ = envelope.success;
463        Ok(envelope.data)
464    }
465
466    /// Returns the current organization profile from `GET /api/v1/orgs/me`.
467    pub async fn org_me(&self) -> Result<OrgProfile> {
468        let response: OrgProfile = self
469            .request_json(
470                Method::GET,
471                "/orgs/me",
472                Option::<&serde_json::Value>::None,
473                true,
474            )
475            .await?;
476        Ok(response)
477    }
478
479    /// Lists API keys for the authenticated organization/user session.
480    pub async fn list_api_keys(&self) -> Result<Vec<SessionApiKeyListItem>> {
481        let response: ListKeysEnvelope = self
482            .request_json(
483                Method::GET,
484                "/keys",
485                Option::<&serde_json::Value>::None,
486                true,
487            )
488            .await?;
489        let _ = response.success;
490        Ok(response.keys)
491    }
492
493    /// Creates a new API key for the authenticated session.
494    ///
495    /// `key_type` may be `Some("standard")`, `Some("platform")`, or `None`
496    /// to let the server default apply.
497    pub async fn create_api_key(
498        &self,
499        name: &str,
500        key_type: Option<&str>,
501    ) -> Result<CreatedApiKey> {
502        #[derive(Serialize)]
503        struct CreateKeyRequest<'a> {
504            name: &'a str,
505            #[serde(skip_serializing_if = "Option::is_none")]
506            r#type: Option<&'a str>,
507        }
508        let response: CreatedApiKey = self
509            .request_json(
510                Method::POST,
511                "/keys",
512                Some(&CreateKeyRequest {
513                    name,
514                    r#type: key_type,
515                }),
516                true,
517            )
518            .await?;
519        Ok(response)
520    }
521
522    /// Deletes an API key by ID.
523    ///
524    /// Returns the server JSON response as-is to avoid over-expanding the SDK surface.
525    pub async fn delete_api_key(&self, id: &str) -> Result<serde_json::Value> {
526        self.request_json(
527            Method::DELETE,
528            &format!("/keys/{id}"),
529            Option::<&serde_json::Value>::None,
530            true,
531        )
532        .await
533    }
534
535    /// Returns current credit balance information from `GET /api/v1/usage/credits`.
536    pub async fn usage_credits(&self) -> Result<UsageCreditsResponse> {
537        self.request_json(
538            Method::GET,
539            "/usage/credits",
540            Option::<&serde_json::Value>::None,
541            true,
542        )
543        .await
544    }
545
546    /// Returns usage statistics from `GET /api/v1/usage/stats`.
547    ///
548    /// When `days` is `None`, the server default period is used.
549    pub async fn usage_stats(&self, days: Option<u32>) -> Result<UsageStatsResponse> {
550        let path = match days {
551            Some(days) => format!("/usage/stats?days={days}"),
552            None => "/usage/stats".to_string(),
553        };
554        self.request_json(Method::GET, &path, Option::<&serde_json::Value>::None, true)
555            .await
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    #[test]
564    fn session_client_uses_v3_base_url() {
565        let client = RainySessionClient::new().expect("session client");
566        assert!(client.base_url().starts_with("https://"));
567        assert_eq!(
568            client.api_v1_url("/auth/login"),
569            format!("{}/api/v1/auth/login", client.base_url())
570        );
571    }
572
573    #[test]
574    fn parses_login_alias_shape() {
575        let payload = r#"{
576          "success": true,
577          "data": {"accessToken":"a","refreshToken":"r","user":{"id":"1","email":"e@x.com","role":"admin"}},
578          "accessToken":"a",
579          "refreshToken":"r",
580          "user":{"id":"1","email":"e@x.com","role":"admin"}
581        }"#;
582        let parsed: LoginResponse = serde_json::from_str(payload).expect("deserialize login");
583        assert_eq!(parsed.access_token, "a");
584        assert_eq!(parsed.refresh_token, "r");
585        assert_eq!(parsed.user.email, "e@x.com");
586    }
587}