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}