1use 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#[derive(Debug, Clone)]
15pub struct SessionConfig {
16 pub base_url: String,
18 pub timeout_seconds: u64,
20 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 pub fn new() -> Self {
37 Self::default()
38 }
39
40 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 pub fn with_timeout(mut self, timeout_seconds: u64) -> Self {
48 self.timeout_seconds = timeout_seconds;
49 self
50 }
51
52 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#[derive(Debug, Clone)]
64pub struct RainySessionClient {
65 client: Client,
66 config: SessionConfig,
67 access_token: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize)]
72pub struct LoginRequest<'a> {
73 pub email: &'a str,
75 pub password: &'a str,
77}
78
79#[derive(Debug, Clone, Serialize)]
81pub struct RegisterRequest<'a> {
82 pub email: &'a str,
84 pub password: &'a str,
86 pub region: &'a str,
88}
89
90#[derive(Debug, Clone, Serialize)]
92pub struct RefreshRequest<'a> {
93 #[serde(rename = "refreshToken")]
95 pub refresh_token: &'a str,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SessionUser {
101 pub id: String,
103 pub email: String,
105 pub role: String,
107 #[serde(rename = "orgId", default)]
109 pub org_id: Option<String>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct SessionTokens {
115 #[serde(rename = "accessToken")]
117 pub access_token: String,
118 #[serde(rename = "refreshToken")]
120 pub refresh_token: String,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct LoginResponse {
126 #[serde(rename = "accessToken")]
128 pub access_token: String,
129 #[serde(rename = "refreshToken")]
131 pub refresh_token: String,
132 pub user: SessionUser,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct RefreshResponse {
139 #[serde(rename = "accessToken")]
141 pub access_token: String,
142 #[serde(rename = "refreshToken")]
144 pub refresh_token: String,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct OrgProfile {
150 pub id: String,
152 pub name: String,
154 #[serde(rename = "planId")]
156 pub plan_id: String,
157 pub region: String,
159 #[serde(rename = "createdAt")]
161 pub created_at: String,
162 pub credits: String,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct SessionApiKeyListItem {
169 pub id: String,
171 pub name: String,
173 #[serde(default)]
175 pub r#type: Option<String>,
176 #[serde(rename = "isActive")]
178 pub is_active: bool,
179 #[serde(rename = "lastUsed", default)]
181 pub last_used: Option<String>,
182 #[serde(rename = "createdAt")]
184 pub created_at: String,
185 #[serde(default)]
187 pub prefix: Option<String>,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct CreatedApiKey {
193 pub key: String,
195 pub id: String,
197 pub name: String,
199 pub r#type: String,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct UsageCreditsResponse {
206 pub balance: f64,
208 pub currency: String,
210 #[serde(default)]
212 pub source: Option<String>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct UsageStatsResponse {
218 #[serde(rename = "periodDays")]
220 pub period_days: u32,
221 #[serde(rename = "totalRequests")]
223 pub total_requests: u64,
224 #[serde(rename = "totalCreditsDeducted")]
226 pub total_credits_deducted: f64,
227 #[serde(rename = "statsByProvider", default)]
229 pub stats_by_provider: serde_json::Value,
230 #[serde(default)]
232 pub logs: Vec<serde_json::Value>,
233 #[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 pub fn new() -> Result<Self> {
253 Self::with_config(SessionConfig::default())
254 }
255
256 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 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 pub fn set_access_token(&mut self, access_token: impl Into<String>) {
303 self.access_token = Some(access_token.into());
304 }
305
306 pub fn clear_access_token(&mut self) {
308 self.access_token = None;
309 }
310
311 pub fn access_token(&self) -> Option<&str> {
313 self.access_token.as_deref()
314 }
315
316 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 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 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 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 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 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 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 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 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 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 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}