Skip to main content

cloudreve_sdk_api/
client.rs

1use crate::error::{ApiError, ApiResponse, ApiResult, ErrorCode, LockConflictDetail};
2use crate::models::user::{RefreshTokenRequest, Token};
3use chrono::{DateTime, Duration, Utc};
4use reqwest::{Client as HttpClient, Method};
5use serde::de::DeserializeOwned;
6use serde::Serialize;
7use std::future::Future;
8use std::pin::Pin;
9use std::sync::Arc;
10use tokio::sync::RwLock;
11
12const API_PREFIX: &str = "/api/v4";
13pub const CR_HEADER_PREFIX: &str = "X-Cr-";
14
15/// Client configuration
16#[derive(Debug, Clone)]
17pub struct ClientConfig {
18    /// Base URL of the Cloudreve instance (e.g., "https://example.com")
19    pub base_url: String,
20    /// Timeout for requests in seconds
21    pub timeout_seconds: u64,
22    /// Client ID
23    pub client_id: String,
24    /// User agent string for HTTP requests
25    pub user_agent: Option<String>,
26}
27
28impl ClientConfig {
29    /// Create a new configuration with the given base URL
30    pub fn new(base_url: impl Into<String>) -> Self {
31        Self {
32            base_url: base_url.into(),
33            timeout_seconds: 60,
34            client_id: "".to_string(),
35            user_agent: None,
36        }
37    }
38
39    /// Set the request timeout
40    pub fn with_timeout(mut self, timeout_seconds: u64) -> Self {
41        self.timeout_seconds = timeout_seconds;
42        self
43    }
44
45    /// Set the client ID
46    pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
47        self.client_id = client_id.into();
48        self
49    }
50
51    /// Set the user agent string
52    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
53        self.user_agent = Some(user_agent.into());
54        self
55    }
56}
57
58/// Token storage with expiration tracking
59#[derive(Debug, Clone)]
60pub(crate) struct TokenStore {
61    access_token: Option<String>,
62    refresh_token: Option<String>,
63    access_token_expires: Option<DateTime<Utc>>,
64    refresh_token_expires: Option<DateTime<Utc>>,
65}
66
67impl TokenStore {
68    fn new() -> Self {
69        Self {
70            access_token: None,
71            refresh_token: None,
72            access_token_expires: None,
73            refresh_token_expires: None,
74        }
75    }
76
77    fn is_access_token_expired(&self) -> bool {
78        self.access_token_expires
79            .map(|exp| Utc::now() >= exp)
80            .unwrap_or(true)
81    }
82
83    fn is_refresh_token_expired(&self) -> bool {
84        self.refresh_token_expires
85            .map(|exp| Utc::now() >= exp)
86            .unwrap_or(true)
87    }
88
89    fn has_tokens(&self) -> bool {
90        self.access_token.is_some() && self.refresh_token.is_some()
91    }
92}
93
94/// Request options for customizing API calls
95#[derive(Debug, Clone, Default)]
96pub struct RequestOptions {
97    /// Don't include authentication credentials
98    pub no_credential: bool,
99    /// Include purchase ticket header
100    pub with_purchase_ticket: bool,
101    /// Skip batch error handling (return first error)
102    pub skip_batch_error: bool,
103    /// Skip lock conflict handling
104    pub skip_lock_conflict: bool,
105}
106
107impl RequestOptions {
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    pub fn no_credential(mut self) -> Self {
113        self.no_credential = true;
114        self
115    }
116
117    pub fn with_purchase_ticket(mut self) -> Self {
118        self.with_purchase_ticket = true;
119        self
120    }
121
122    pub fn skip_batch_error(mut self) -> Self {
123        self.skip_batch_error = true;
124        self
125    }
126
127    pub fn skip_lock_conflict(mut self) -> Self {
128        self.skip_lock_conflict = true;
129        self
130    }
131}
132
133/// Callback type for credential refresh events
134pub type OnCredentialRefreshed =
135    Arc<dyn Fn(Token) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
136
137/// Callback type for credential invalid/expired events (401, 40020, 40089)
138pub type OnCredentialInvalid =
139    Arc<dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
140
141/// Main Cloudreve API client
142pub struct Client {
143    pub(crate) config: ClientConfig,
144    pub(crate) http_client: HttpClient,
145    pub(crate) tokens: Arc<RwLock<TokenStore>>,
146    pub(crate) purchase_ticket: Arc<RwLock<Option<String>>>,
147    on_credential_refreshed: Option<OnCredentialRefreshed>,
148    on_credential_invalid: Option<OnCredentialInvalid>,
149}
150
151impl Client {
152    /// Create a new API client
153    pub fn new(config: ClientConfig) -> Self {
154        let mut builder = HttpClient::builder()
155            .connect_timeout(std::time::Duration::from_secs(config.timeout_seconds));
156
157        if let Some(ref user_agent) = config.user_agent {
158            builder = builder.user_agent(user_agent);
159        }
160
161        let http_client = builder.build().expect("Failed to create HTTP client");
162
163        Self {
164            config,
165            http_client,
166            tokens: Arc::new(RwLock::new(TokenStore::new())),
167            purchase_ticket: Arc::new(RwLock::new(None)),
168            on_credential_refreshed: None,
169            on_credential_invalid: None,
170        }
171    }
172
173    /// Set a callback to be invoked when credentials are refreshed
174    ///
175    /// The callback receives the new token information and can perform async operations
176    /// such as persisting tokens to storage.
177    ///
178    /// # Example
179    /// ```no_run
180    /// use cloudreve_sdk_api::{Client, ClientConfig};
181    /// use std::sync::Arc;
182    ///
183    /// let mut client = Client::new(ClientConfig::new("https://cloudreve.example"));
184    /// client.set_on_credential_refreshed(Arc::new(|token| {
185    ///     Box::pin(async move {
186    ///         // Save token to storage
187    ///         println!("New access token: {}", token.access_token);
188    ///     })
189    /// }));
190    /// ```
191    pub fn set_on_credential_refreshed(&mut self, callback: OnCredentialRefreshed) {
192        self.on_credential_refreshed = Some(callback);
193    }
194
195    /// Clear the credential refresh callback
196    pub fn clear_on_credential_refreshed(&mut self) {
197        self.on_credential_refreshed = None;
198    }
199
200    /// Set a callback to be invoked when credentials are invalid or expired
201    ///
202    /// This callback is triggered when the API returns error codes indicating
203    /// authentication failure: 401 (LoginRequired), 40020 (CredentialInvalid),
204    /// or 40089 (SessionExpired).
205    pub fn set_on_credential_invalid(&mut self, callback: OnCredentialInvalid) {
206        self.on_credential_invalid = Some(callback);
207    }
208
209    /// Clear the credential invalid callback
210    pub fn clear_on_credential_invalid(&mut self) {
211        self.on_credential_invalid = None;
212    }
213
214    /// Invoke the credential invalid callback if set
215    async fn notify_credential_invalid(&self) {
216        if let Some(ref callback) = self.on_credential_invalid {
217            callback().await;
218        }
219    }
220
221    /// Set authentication tokens
222    pub async fn set_tokens(&self, access_token: String, refresh_token: String) {
223        let mut store = self.tokens.write().await;
224
225        // Parse expiration from token if available, otherwise use default
226        // In a real implementation, you might want to parse JWT tokens
227        let access_expires = Utc::now() + Duration::hours(1);
228        let refresh_expires = Utc::now() + Duration::days(7);
229
230        store.access_token = Some(access_token);
231        store.refresh_token = Some(refresh_token);
232        store.access_token_expires = Some(access_expires);
233        store.refresh_token_expires = Some(refresh_expires);
234    }
235
236    /// Set tokens from a Token response with explicit expiration times.
237    pub async fn set_tokens_with_expiry(&self, token: &Token) -> ApiResult<()> {
238        let mut store = self.tokens.write().await;
239
240        store.access_token = Some(token.access_token.clone());
241        store.refresh_token = Some(token.refresh_token.clone());
242
243        // Parse RFC3339 timestamps
244        if let Ok(exp) = DateTime::parse_from_rfc3339(&token.access_expires) {
245            store.access_token_expires = Some(exp.with_timezone(&Utc));
246        }
247        if let Ok(exp) = DateTime::parse_from_rfc3339(&token.refresh_expires) {
248            store.refresh_token_expires = Some(exp.with_timezone(&Utc));
249        }
250
251        Ok(())
252    }
253
254    /// Clear all authentication tokens
255    pub async fn clear_tokens(&self) {
256        let mut store = self.tokens.write().await;
257        *store = TokenStore::new();
258    }
259
260    /// Set the purchase ticket for subsequent requests
261    pub async fn set_purchase_ticket(&self, ticket: Option<String>) {
262        let mut pt = self.purchase_ticket.write().await;
263        *pt = ticket;
264    }
265
266    /// Get a valid access token, refreshing if necessary
267    pub(crate) async fn get_access_token(&self) -> ApiResult<String> {
268        let store = self.tokens.read().await;
269
270        // Check if we have tokens
271        if !store.has_tokens() {
272            self.notify_credential_invalid().await;
273            return Err(ApiError::NoTokensAvailable);
274        }
275
276        // Check if refresh token is expired
277        if store.is_refresh_token_expired() {
278            self.notify_credential_invalid().await;
279            return Err(ApiError::RefreshTokenExpired);
280        }
281
282        // If access token is not expired, return it
283        if !store.is_access_token_expired() {
284            return Ok(store.access_token.clone().unwrap());
285        }
286
287        // Access token expired, need to refresh
288        drop(store); // Release read lock before calling refresh
289
290        self.refresh_access_token().await
291    }
292
293    /// Refresh the access token using the refresh token
294    async fn refresh_access_token(&self) -> ApiResult<String> {
295        let refresh_token = {
296            let store = self.tokens.read().await;
297            store
298                .refresh_token
299                .clone()
300                .ok_or(ApiError::NoTokensAvailable)?
301        };
302
303        // Call refresh token API without credentials - use direct HTTP call to avoid recursion
304        let url = self.build_url("/session/token/refresh");
305        let request = RefreshTokenRequest { refresh_token };
306
307        let response = self.http_client.post(&url).json(&request).send().await?;
308
309        let api_response: ApiResponse<Token> = response.json().await?;
310
311        if api_response.code != ErrorCode::Success as i32 {
312            if let Some(error_code) = ErrorCode::from_code(api_response.code) {
313                if error_code.is_credential_error() {
314                    self.notify_credential_invalid().await;
315                }
316            }
317            return Err(ApiError::from_response(api_response));
318        }
319
320        let token = api_response
321            .data
322            .ok_or_else(|| ApiError::Other("No token in response".to_string()))?;
323
324        // Update tokens
325        self.set_tokens_with_expiry(&token).await?;
326
327        // Invoke callback if set
328        if let Some(ref callback) = self.on_credential_refreshed {
329            callback(token.clone()).await;
330        }
331
332        Ok(token.access_token)
333    }
334
335    /// Build the full URL for an API endpoint
336    pub(crate) fn build_url(&self, path: &str) -> String {
337        format!("{}{}{}", self.config.base_url, API_PREFIX, path)
338    }
339
340    /// Internal send method that handles the actual HTTP request
341    async fn send_internal<T, R>(
342        &self,
343        path: &str,
344        method: Method,
345        body: Option<&T>,
346        options: RequestOptions,
347    ) -> ApiResult<R>
348    where
349        T: Serialize + ?Sized,
350        R: DeserializeOwned + Default,
351    {
352        let url = self.build_url(path);
353        let mut request = self.http_client.request(method, &url);
354
355        // Add authentication header if needed
356        if !options.no_credential {
357            let token = self.get_access_token().await?;
358            request = request.header("Authorization", format!("Bearer {}", token));
359        }
360
361        // Add client ID header if set
362        if !self.config.client_id.is_empty() {
363            request = request.header(
364                format!("{}Client-Id", CR_HEADER_PREFIX),
365                self.config.client_id.clone(),
366            );
367        }
368
369        // Add purchase ticket if requested
370        if options.with_purchase_ticket {
371            let ticket = self.purchase_ticket.read().await;
372            if let Some(t) = ticket.as_ref() {
373                request = request.header(format!("{}Purchase-Ticket", CR_HEADER_PREFIX), t);
374            }
375        }
376
377        // Add body if present
378        if let Some(body) = body {
379            request = request.json(body);
380        }
381
382        // Execute request
383        let response = request.send().await?;
384        let response_text = response.text().await?;
385
386        // First parse as a generic Value to check the error code
387        let raw_value: serde_json::Value = serde_json::from_str(&response_text)?;
388
389        let code = raw_value.get("code").and_then(|c| c.as_i64()).unwrap_or(0) as i32;
390
391        // Handle lock conflict specially - data contains LockConflictDetail
392        if code == ErrorCode::LockConflict as i32 {
393            let msg = raw_value
394                .get("msg")
395                .and_then(|m| m.as_str())
396                .unwrap_or("")
397                .to_string();
398            let detail: Option<LockConflictDetail> = raw_value
399                .get("data")
400                .and_then(|d| serde_json::from_value(d.clone()).ok());
401            return Err(ApiError::LockConflict {
402                message: msg,
403                detail,
404            });
405        }
406
407        // Parse as the expected response type
408        let api_response: ApiResponse<R> = serde_json::from_str(&response_text)?;
409
410        // Check response code
411        if api_response.code != ErrorCode::Success as i32 {
412            // Check if this is a credential error and invoke callback
413            if let Some(error_code) = ErrorCode::from_code(api_response.code) {
414                if error_code.is_credential_error() {
415                    self.notify_credential_invalid().await;
416                }
417            }
418            return Err(ApiError::from_response(api_response));
419        }
420
421        // Return data
422        Ok(api_response.data.unwrap_or_default())
423    }
424
425    /// Send an API request with automatic token refresh
426    pub async fn send<T, R>(
427        &self,
428        path: &str,
429        method: Method,
430        body: Option<&T>,
431        options: RequestOptions,
432    ) -> ApiResult<R>
433    where
434        T: Serialize + ?Sized,
435        R: DeserializeOwned + Default,
436    {
437        match self
438            .send_internal(path, method.clone(), body, options.clone())
439            .await
440        {
441            Ok(result) => Ok(result),
442            Err(ApiError::AccessTokenExpired) => {
443                // Token expired, refresh and retry
444                self.refresh_access_token().await?;
445                self.send_internal(path, method, body, options).await
446            }
447            Err(e) => Err(e),
448        }
449    }
450
451    /// Send a GET request
452    pub async fn get<R>(&self, path: &str, options: RequestOptions) -> ApiResult<R>
453    where
454        R: DeserializeOwned + Default,
455    {
456        self.send::<(), R>(path, Method::GET, None, options).await
457    }
458
459    /// Send a POST request
460    pub async fn post<T, R>(&self, path: &str, body: &T, options: RequestOptions) -> ApiResult<R>
461    where
462        T: Serialize,
463        R: DeserializeOwned + Default,
464    {
465        self.send(path, Method::POST, Some(body), options).await
466    }
467
468    /// Send a PUT request
469    pub async fn put<T, R>(&self, path: &str, body: &T, options: RequestOptions) -> ApiResult<R>
470    where
471        T: Serialize,
472        R: DeserializeOwned + Default,
473    {
474        self.send(path, Method::PUT, Some(body), options).await
475    }
476
477    /// Send a DELETE request
478    pub async fn delete<R>(&self, path: &str, options: RequestOptions) -> ApiResult<R>
479    where
480        R: DeserializeOwned + Default,
481    {
482        self.send::<(), R>(path, Method::DELETE, None, options)
483            .await
484    }
485
486    /// Send a DELETE request with body
487    pub async fn delete_with_body<T, R>(
488        &self,
489        path: &str,
490        body: &T,
491        options: RequestOptions,
492    ) -> ApiResult<R>
493    where
494        T: Serialize,
495        R: DeserializeOwned + Default,
496    {
497        self.send(path, Method::DELETE, Some(body), options).await
498    }
499
500    /// Send a PATCH request
501    pub async fn patch<T, R>(&self, path: &str, body: &T, options: RequestOptions) -> ApiResult<R>
502    where
503        T: Serialize,
504        R: DeserializeOwned + Default,
505    {
506        self.send(path, Method::PATCH, Some(body), options).await
507    }
508}