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}