Skip to main content

rainy_sdk/
auth.rs

1use crate::error::{RainyError, Result};
2use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT};
3use secrecy::{ExposeSecret, Secret};
4use std::time::Duration;
5
6/// Configuration for authentication and client behavior.
7///
8/// `AuthConfig` holds all the necessary information for authenticating with the Rainy API,
9/// as well as settings for request behavior like timeouts and retries.
10///
11/// # Examples
12///
13/// ```rust
14/// use rainy_sdk::auth::AuthConfig;
15///
16/// let config = AuthConfig::new("your-api-key")
17///     .with_base_url("https://api.example.com")
18///     .with_timeout(60)
19///     .with_max_retries(5);
20///
21/// assert_eq!(config.base_url, "https://api.example.com");
22/// assert_eq!(config.timeout_seconds, 60);
23/// assert_eq!(config.max_retries, 5);
24/// ```
25#[derive(Debug, Clone)]
26pub struct AuthConfig {
27    /// The API key used for authenticating with the Rainy API.
28    pub api_key: Secret<String>,
29
30    /// The base URL of the Rainy API. Defaults to the official endpoint.
31    pub base_url: String,
32
33    /// The timeout for HTTP requests, in seconds.
34    pub timeout_seconds: u64,
35
36    /// The maximum number of times to retry a failed request.
37    pub max_retries: u32,
38
39    /// A flag to enable or disable automatic retries with exponential backoff.
40    pub enable_retry: bool,
41
42    /// The user agent string to send with each request.
43    pub user_agent: String,
44}
45
46impl AuthConfig {
47    /// Creates a new `AuthConfig` with the given API key and default settings.
48    ///
49    /// # Arguments
50    ///
51    /// * `api_key` - Your Rainy API key.
52    pub fn new(api_key: impl Into<String>) -> Self {
53        Self {
54            api_key: Secret::new(api_key.into()),
55            base_url: crate::DEFAULT_BASE_URL.to_string(),
56            timeout_seconds: 30,
57            max_retries: 3,
58            enable_retry: true,
59            user_agent: format!("rainy-sdk/{}", crate::VERSION),
60        }
61    }
62
63    /// Sets a custom base URL for the API.
64    ///
65    /// # Arguments
66    ///
67    /// * `base_url` - The new base URL to use.
68    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
69        self.base_url = base_url.into();
70        self
71    }
72
73    /// Sets a custom timeout for HTTP requests.
74    ///
75    /// # Arguments
76    ///
77    /// * `seconds` - The timeout duration in seconds.
78    pub fn with_timeout(mut self, seconds: u64) -> Self {
79        self.timeout_seconds = seconds;
80        self
81    }
82
83    /// Sets the maximum number of retry attempts for failed requests.
84    ///
85    /// # Arguments
86    ///
87    /// * `retries` - The maximum number of retries.
88    pub fn with_max_retries(mut self, retries: u32) -> Self {
89        self.max_retries = retries;
90        self
91    }
92
93    /// Enables or disables automatic retries.
94    ///
95    /// # Arguments
96    ///
97    /// * `enable` - `true` to enable retries, `false` to disable.
98    pub fn with_retry(mut self, enable: bool) -> Self {
99        self.enable_retry = enable;
100        self
101    }
102
103    /// Sets a custom user agent string for requests.
104    ///
105    /// # Arguments
106    ///
107    /// * `user_agent` - The new user agent string.
108    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
109        self.user_agent = user_agent.into();
110        self
111    }
112
113    /// Validates the `AuthConfig` settings.
114    ///
115    /// This method checks for common configuration errors, such as an empty API key
116    /// or an invalid base URL.
117    ///
118    /// Supports two API key formats:
119    /// - Standard: `ra-{48 hex}` = 51 characters
120    /// - Cowork: `ra-cowork{48 hex}` = 57 characters
121    ///
122    /// # Returns
123    ///
124    /// A `Result` that is `Ok(())` if the configuration is valid, or a `RainyError` if it's not.
125    pub fn validate(&self) -> Result<()> {
126        if self.api_key.expose_secret().is_empty() {
127            return Err(RainyError::Authentication {
128                code: "EMPTY_API_KEY".to_string(),
129                message: "API key cannot be empty".to_string(),
130                retryable: false,
131            });
132        }
133
134        let key = self.api_key.expose_secret();
135
136        // Validate API key format based on type
137        if key.starts_with("ra-cowork") {
138            // Cowork key: ra-cowork (9 chars) + 48 hex = 57 chars
139            if key.len() != 57 {
140                return Err(RainyError::Authentication {
141                    code: "INVALID_COWORK_API_KEY_FORMAT".to_string(),
142                    message: "Cowork API key must be 57 characters (ra-cowork + 48 hex)"
143                        .to_string(),
144                    retryable: false,
145                });
146            }
147        } else if key.starts_with("ra-") {
148            // Standard key: ra- (3 chars) + 48 hex = 51 chars
149            if key.len() != 51 {
150                return Err(RainyError::Authentication {
151                    code: "INVALID_API_KEY_FORMAT".to_string(),
152                    message: "Standard API key must be 51 characters (ra- + 48 hex)".to_string(),
153                    retryable: false,
154                });
155            }
156        } else {
157            return Err(RainyError::Authentication {
158                code: "INVALID_API_KEY_FORMAT".to_string(),
159                message: "API key must start with 'ra-' or 'ra-cowork'".to_string(),
160                retryable: false,
161            });
162        }
163
164        // Validate URL format
165        if url::Url::parse(&self.base_url).is_err() {
166            return Err(RainyError::InvalidRequest {
167                code: "INVALID_BASE_URL".to_string(),
168                message: "Base URL is not a valid URL".to_string(),
169                details: None,
170            });
171        }
172
173        Ok(())
174    }
175
176    /// Check if this is a Cowork-specific API key.
177    ///
178    /// Cowork keys use the format `ra-cowork{48 hex}` and are meant for
179    /// the Rainy Cowork desktop application with subscription-based billing.
180    ///
181    /// # Returns
182    /// `true` if the key starts with `ra-cowork`, `false` otherwise.
183    pub fn is_cowork_key(&self) -> bool {
184        self.api_key.expose_secret().starts_with("ra-cowork")
185    }
186
187    /// Builds the necessary HTTP headers for an API request.
188    ///
189    /// This method constructs a `HeaderMap` containing the `Authorization` and `User-Agent`
190    /// headers based on the `AuthConfig`.
191    ///
192    /// # Returns
193    ///
194    /// A `Result` containing the `HeaderMap` or a `RainyError` if header creation fails.
195    pub fn build_headers(&self) -> Result<HeaderMap> {
196        let mut headers = HeaderMap::new();
197
198        // Set User-Agent
199        headers.insert(USER_AGENT, HeaderValue::from_str(&self.user_agent)?);
200
201        // Set Content-Type for JSON requests
202        headers.insert(
203            reqwest::header::CONTENT_TYPE,
204            HeaderValue::from_static("application/json"),
205        );
206
207        // Set authorization header
208        let auth_value = format!("Bearer {}", self.api_key.expose_secret());
209        headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?);
210
211        Ok(headers)
212    }
213
214    /// Returns the request timeout as a `Duration`.
215    pub fn timeout(&self) -> Duration {
216        Duration::from_secs(self.timeout_seconds)
217    }
218}
219
220impl std::fmt::Display for AuthConfig {
221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222        write!(
223            f,
224            "AuthConfig {{ base_url: {}, timeout: {}s, retries: {} }}",
225            self.base_url, self.timeout_seconds, self.max_retries
226        )
227    }
228}
229
230/// A simple rate limiter.
231///
232/// This rate limiter is deprecated and should not be used in new code.
233/// The `RainyClient` now uses a more robust, feature-flagged rate limiting mechanism
234/// based on the `governor` crate.
235#[deprecated(note = "Use the governor-based rate limiting in RainyClient instead")]
236#[derive(Debug)]
237pub struct RateLimiter {
238    requests_per_minute: u32,
239    last_request: std::time::Instant,
240    request_count: u32,
241}
242
243#[allow(deprecated)]
244impl RateLimiter {
245    /// Creates a new `RateLimiter`.
246    ///
247    /// # Arguments
248    ///
249    /// * `requests_per_minute` - The maximum number of requests allowed per minute.
250    pub fn new(requests_per_minute: u32) -> Self {
251        Self {
252            requests_per_minute,
253            last_request: std::time::Instant::now(),
254            request_count: 0,
255        }
256    }
257
258    /// Pauses execution if the rate limit has been exceeded.
259    ///
260    /// This method will asynchronously wait until the next request can be sent without
261    /// violating the rate limit.
262    pub async fn wait_if_needed(&mut self) -> Result<()> {
263        let now = std::time::Instant::now();
264        let elapsed = now.duration_since(self.last_request);
265
266        // Reset counter if a minute has passed
267        if elapsed >= Duration::from_secs(60) {
268            self.request_count = 0;
269            self.last_request = now;
270        }
271
272        // Check if we've exceeded the rate limit
273        if self.request_count >= self.requests_per_minute {
274            let wait_time = Duration::from_secs(60) - elapsed;
275            tokio::time::sleep(wait_time).await;
276            self.request_count = 0;
277            self.last_request = std::time::Instant::now();
278        }
279
280        self.request_count += 1;
281        Ok(())
282    }
283}