rain_sdk/
client.rs

1//! Main HTTP client for the Rain SDK
2//!
3//! This module provides the core HTTP client for making requests to the Rain API.
4//! The client supports both async and blocking (synchronous) operations.
5//!
6//! # Features
7//!
8//! - **Async Support**: Use `async`/`await` for non-blocking operations
9//! - **Blocking Support**: Use synchronous methods for simpler code
10//! - **Automatic Authentication**: Handles API key authentication
11//! - **Error Handling**: Comprehensive error types with detailed context
12//!
13//! # Examples
14//!
15//! ## Async Client
16//!
17//! ```no_run
18//! use rain_sdk::{RainClient, Config, Environment, AuthConfig};
19//!
20//! # #[cfg(feature = "async")]
21//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
22//! let config = Config::new(Environment::Dev);
23//! let auth = AuthConfig::with_api_key("your-api-key".to_string());
24//! let client = RainClient::new(config, auth)?;
25//!
26//! // Use async methods
27//! # Ok(())
28//! # }
29//! ```
30//!
31//! ## Blocking Client
32//!
33//! ```no_run
34//! use rain_sdk::{RainClient, Config, Environment, AuthConfig};
35//!
36//! # #[cfg(feature = "sync")]
37//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
38//! let config = Config::new(Environment::Dev);
39//! let auth = AuthConfig::with_api_key("your-api-key".to_string());
40//! let client = RainClient::new(config, auth)?;
41//!
42//! // Use blocking methods
43//! # Ok(())
44//! # }
45//! ```
46
47use crate::auth::AuthConfig;
48use crate::config::Config;
49use crate::error::{RainError, Result};
50use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, CONTENT_TYPE};
51use serde::de::DeserializeOwned;
52use url::Url;
53
54/// Main client for interacting with the Rain API
55///
56/// This client provides methods to interact with all Rain API endpoints.
57/// It handles authentication, request building, and response parsing automatically.
58///
59/// # Thread Safety
60///
61/// The client is `Clone` and can be shared across threads safely.
62///
63/// # Examples
64///
65/// ```no_run
66/// use rain_sdk::{RainClient, Config, Environment, AuthConfig};
67///
68/// # #[cfg(feature = "async")]
69/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
70/// let config = Config::new(Environment::Dev);
71/// let auth = AuthConfig::with_api_key("your-api-key".to_string());
72/// let client = RainClient::new(config, auth)?;
73/// # Ok(())
74/// # }
75/// ```
76#[derive(Clone)]
77pub struct RainClient {
78    config: Config,
79    auth_config: AuthConfig,
80    #[cfg(feature = "async")]
81    client: reqwest::Client,
82    #[cfg(feature = "sync")]
83    blocking_client: reqwest::blocking::Client,
84}
85
86impl RainClient {
87    /// Create a new client with the given configuration
88    ///
89    /// # Arguments
90    ///
91    /// * `config` - Client configuration (environment, timeout, etc.)
92    /// * `auth_config` - Authentication configuration (API key)
93    ///
94    /// # Returns
95    ///
96    /// Returns a new `RainClient` instance ready to make API requests.
97    ///
98    /// # Errors
99    ///
100    /// This function will return an error if:
101    /// - The HTTP client cannot be created
102    /// - The user agent string is invalid
103    ///
104    /// # Examples
105    ///
106    /// ```no_run
107    /// use rain_sdk::{RainClient, Config, Environment, AuthConfig};
108    ///
109    /// # #[cfg(feature = "async")]
110    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
111    /// let config = Config::new(Environment::Dev);
112    /// let auth = AuthConfig::with_api_key("your-api-key".to_string());
113    /// let client = RainClient::new(config, auth)?;
114    /// # Ok(())
115    /// # }
116    /// ```
117    pub fn new(config: Config, auth_config: AuthConfig) -> Result<Self> {
118        #[cfg(feature = "async")]
119        let client = {
120            let mut headers = HeaderMap::new();
121            headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
122            headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
123            headers.insert(
124                "User-Agent",
125                HeaderValue::from_str(&config.user_agent)
126                    .map_err(|e| RainError::Other(anyhow::anyhow!("Invalid user agent: {e}")))?,
127            );
128
129            reqwest::Client::builder()
130                .default_headers(headers)
131                .timeout(std::time::Duration::from_secs(config.timeout_secs))
132                .redirect(reqwest::redirect::Policy::limited(10))
133                .build()
134                .map_err(RainError::HttpError)?
135        };
136
137        #[cfg(feature = "sync")]
138        let blocking_client = {
139            let mut headers = HeaderMap::new();
140            headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
141            headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
142            headers.insert(
143                "User-Agent",
144                HeaderValue::from_str(&config.user_agent)
145                    .map_err(|e| RainError::Other(anyhow::anyhow!("Invalid user agent: {e}")))?,
146            );
147
148            reqwest::blocking::Client::builder()
149                .default_headers(headers)
150                .timeout(std::time::Duration::from_secs(config.timeout_secs))
151                .redirect(reqwest::redirect::Policy::limited(10))
152                .build()
153                .map_err(|e| {
154                    RainError::Other(anyhow::anyhow!("Failed to create blocking client: {e}"))
155                })?
156        };
157
158        Ok(Self {
159            config,
160            auth_config,
161            #[cfg(feature = "async")]
162            client,
163            #[cfg(feature = "sync")]
164            blocking_client,
165        })
166    }
167
168    /// Get the base URL for API requests
169    ///
170    /// Returns the base URL that all API requests will be made against.
171    ///
172    /// # Examples
173    ///
174    /// ```no_run
175    /// use rain_sdk::{RainClient, Config, Environment, AuthConfig};
176    ///
177    /// # #[cfg(feature = "async")]
178    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
179    /// let config = Config::new(Environment::Dev);
180    /// let auth = AuthConfig::with_api_key("your-api-key".to_string());
181    /// let client = RainClient::new(config, auth)?;
182    ///
183    /// let base_url = client.base_url();
184    /// println!("API base URL: {}", base_url);
185    /// # Ok(())
186    /// # }
187    /// ```
188    pub fn base_url(&self) -> &Url {
189        &self.config.base_url
190    }
191
192    /// Build a full URL from a path
193    fn build_url(&self, path: &str) -> Result<Url> {
194        // If path starts with /, we need to preserve the base URL's path
195        let path_to_join = path.strip_prefix('/').unwrap_or(path);
196
197        let mut url = self.config.base_url.clone();
198        url.path_segments_mut()
199            .map_err(|_| RainError::Other(anyhow::anyhow!("Cannot be a base URL")))?
200            .pop_if_empty()
201            .extend(path_to_join.split('/').filter(|s| !s.is_empty()));
202
203        Ok(url)
204    }
205
206    #[cfg(feature = "async")]
207    /// Make an async GET request
208    pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
209        let url = self.build_url(path)?;
210        let builder = self.client.get(url.as_str());
211        let builder = crate::auth::add_auth_headers_async(builder, &self.auth_config);
212
213        let response = builder.send().await?;
214        self.handle_response(response).await
215    }
216
217    #[cfg(feature = "async")]
218    /// Make an async GET request and return raw bytes
219    pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
220        let url = self.build_url(path)?;
221        let builder = self.client.get(url.as_str());
222        let builder = crate::auth::add_auth_headers_async(builder, &self.auth_config);
223
224        let response = builder.send().await?;
225        let status = response.status();
226        if status.is_success() {
227            let bytes = response.bytes().await?;
228            Ok(bytes.to_vec())
229        } else {
230            let text = response.text().await?;
231            Err(RainError::Other(anyhow::anyhow!("HTTP {status}: {text}")))
232        }
233    }
234
235    #[cfg(feature = "async")]
236    /// Make an async POST request
237    pub async fn post<T: DeserializeOwned, B: serde::Serialize>(
238        &self,
239        path: &str,
240        body: &B,
241    ) -> Result<T> {
242        let url = self.build_url(path)?;
243        let body_bytes = serde_json::to_vec(body)?;
244        let builder = self.client.post(url.as_str()).body(body_bytes.clone());
245        let builder = crate::auth::add_auth_headers_async(builder, &self.auth_config);
246
247        let response = builder.send().await?;
248        self.handle_response(response).await
249    }
250
251    #[cfg(feature = "async")]
252    /// Make an async PATCH request
253    pub async fn patch<T: DeserializeOwned, B: serde::Serialize>(
254        &self,
255        path: &str,
256        body: &B,
257    ) -> Result<T> {
258        let url = self.build_url(path)?;
259        let body_bytes = serde_json::to_vec(body)?;
260        let builder = self.client.patch(url.as_str()).body(body_bytes.clone());
261        let builder = crate::auth::add_auth_headers_async(builder, &self.auth_config);
262
263        let response = builder.send().await?;
264        self.handle_response(response).await
265    }
266
267    #[cfg(feature = "async")]
268    /// Make an async PUT request
269    pub async fn put<T: DeserializeOwned, B: serde::Serialize>(
270        &self,
271        path: &str,
272        body: &B,
273    ) -> Result<T> {
274        let url = self.build_url(path)?;
275        let body_bytes = serde_json::to_vec(body)?;
276        let builder = self.client.put(url.as_str()).body(body_bytes.clone());
277        let builder = crate::auth::add_auth_headers_async(builder, &self.auth_config);
278
279        let response = builder.send().await?;
280        self.handle_response(response).await
281    }
282
283    #[cfg(feature = "async")]
284    /// Make an async GET request with custom headers
285    pub async fn get_with_headers<T: DeserializeOwned>(
286        &self,
287        path: &str,
288        headers: Vec<(&str, &str)>,
289    ) -> Result<T> {
290        let url = self.build_url(path)?;
291        let mut builder = self.client.get(url.as_str());
292        builder = crate::auth::add_auth_headers_async(builder, &self.auth_config);
293
294        for (key, value) in headers {
295            builder = builder.header(key, value);
296        }
297
298        let response = builder.send().await?;
299        self.handle_response(response).await
300    }
301
302    #[cfg(feature = "async")]
303    /// Make an async PUT request with custom headers
304    pub async fn put_with_headers<T: DeserializeOwned, B: serde::Serialize>(
305        &self,
306        path: &str,
307        body: &B,
308        headers: Vec<(&str, &str)>,
309    ) -> Result<T> {
310        let url = self.build_url(path)?;
311        let body_bytes = serde_json::to_vec(body)?;
312        let mut builder = self.client.put(url.as_str()).body(body_bytes.clone());
313        builder = crate::auth::add_auth_headers_async(builder, &self.auth_config);
314
315        for (key, value) in headers {
316            builder = builder.header(key, value);
317        }
318
319        let response = builder.send().await?;
320        self.handle_response(response).await
321    }
322
323    #[cfg(feature = "async")]
324    /// Make an async DELETE request
325    pub async fn delete(&self, path: &str) -> Result<()> {
326        let url = self.build_url(path)?;
327        let builder = self.client.delete(url.as_str());
328        let builder = crate::auth::add_auth_headers_async(builder, &self.auth_config);
329
330        let response = builder.send().await?;
331        let status = response.status();
332        if status.is_success() || status == reqwest::StatusCode::NO_CONTENT {
333            Ok(())
334        } else {
335            let text = response.text().await?;
336            Err(RainError::Other(anyhow::anyhow!("HTTP {status}: {text}")))
337        }
338    }
339
340    #[cfg(feature = "async")]
341    /// Make an async PUT request with multipart form data
342    pub async fn put_multipart<T: DeserializeOwned>(
343        &self,
344        path: &str,
345        form: reqwest::multipart::Form,
346    ) -> Result<T> {
347        let url = self.build_url(path)?;
348        let mut headers = HeaderMap::new();
349        // Don't set Content-Type for multipart - reqwest will set it with boundary
350        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
351        headers.insert(
352            "User-Agent",
353            HeaderValue::from_str(&self.config.user_agent)
354                .map_err(|e| RainError::Other(anyhow::anyhow!("Invalid user agent: {e}")))?,
355        );
356
357        let request = self
358            .client
359            .put(url.as_str())
360            .headers(headers)
361            .header("Api-Key", &self.auth_config.api_key)
362            .multipart(form);
363
364        let response = request.send().await?;
365        self.handle_response(response).await
366    }
367
368    #[cfg(feature = "async")]
369    /// Make an async PUT request with multipart form data that returns nothing (204)
370    pub async fn put_multipart_no_content(
371        &self,
372        path: &str,
373        form: reqwest::multipart::Form,
374    ) -> Result<()> {
375        let url = self.build_url(path)?;
376        let mut headers = HeaderMap::new();
377        // Don't set Content-Type for multipart - reqwest will set it with boundary
378        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
379        headers.insert(
380            "User-Agent",
381            HeaderValue::from_str(&self.config.user_agent)
382                .map_err(|e| RainError::Other(anyhow::anyhow!("Invalid user agent: {e}")))?,
383        );
384
385        let request = self
386            .client
387            .put(url.as_str())
388            .headers(headers)
389            .header("Api-Key", &self.auth_config.api_key)
390            .multipart(form);
391
392        let response = request.send().await?;
393        let status = response.status();
394        if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
395            Ok(())
396        } else {
397            let text = response.text().await?;
398            Err(RainError::Other(anyhow::anyhow!("HTTP {status}: {text}")))
399        }
400    }
401
402    #[cfg(feature = "sync")]
403    /// Make a blocking GET request
404    pub fn get_blocking<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
405        let url = self.build_url(path)?;
406        let builder = self.blocking_client.get(url.as_str());
407        let builder = crate::auth::add_auth_headers_sync(builder, &self.auth_config);
408
409        let response = builder.send()?;
410        self.handle_blocking_response(response)
411    }
412
413    #[cfg(feature = "sync")]
414    /// Make a blocking GET request and return raw bytes
415    pub fn get_bytes_blocking(&self, path: &str) -> Result<Vec<u8>> {
416        let url = self.build_url(path)?;
417        let builder = self.blocking_client.get(url.as_str());
418        let builder = crate::auth::add_auth_headers_sync(builder, &self.auth_config);
419
420        let response = builder.send()?;
421        let status = response.status();
422        if status.is_success() {
423            let bytes = response.bytes()?;
424            Ok(bytes.to_vec())
425        } else {
426            let text = response.text()?;
427            Err(RainError::Other(anyhow::anyhow!("HTTP {status}: {text}")))
428        }
429    }
430
431    #[cfg(feature = "sync")]
432    /// Make a blocking POST request
433    pub fn post_blocking<T: DeserializeOwned, B: serde::Serialize>(
434        &self,
435        path: &str,
436        body: &B,
437    ) -> Result<T> {
438        let url = self.build_url(path)?;
439        let body_bytes = serde_json::to_vec(body)?;
440        let builder = self
441            .blocking_client
442            .post(url.as_str())
443            .body(body_bytes.clone());
444        let builder = crate::auth::add_auth_headers_sync(builder, &self.auth_config);
445
446        let response = builder.send()?;
447        self.handle_blocking_response(response)
448    }
449
450    #[cfg(feature = "sync")]
451    /// Make a blocking PATCH request
452    pub fn patch_blocking<T: DeserializeOwned, B: serde::Serialize>(
453        &self,
454        path: &str,
455        body: &B,
456    ) -> Result<T> {
457        let url = self.build_url(path)?;
458        let body_bytes = serde_json::to_vec(body)?;
459        let builder = self
460            .blocking_client
461            .patch(url.as_str())
462            .body(body_bytes.clone());
463        let builder = crate::auth::add_auth_headers_sync(builder, &self.auth_config);
464
465        let response = builder.send()?;
466        self.handle_blocking_response(response)
467    }
468
469    #[cfg(feature = "sync")]
470    /// Make a blocking PUT request
471    pub fn put_blocking<T: DeserializeOwned, B: serde::Serialize>(
472        &self,
473        path: &str,
474        body: &B,
475    ) -> Result<T> {
476        let url = self.build_url(path)?;
477        let body_bytes = serde_json::to_vec(body)?;
478        let builder = self
479            .blocking_client
480            .put(url.as_str())
481            .body(body_bytes.clone());
482        let builder = crate::auth::add_auth_headers_sync(builder, &self.auth_config);
483
484        let response = builder.send()?;
485        self.handle_blocking_response(response)
486    }
487
488    #[cfg(feature = "sync")]
489    /// Make a blocking PUT request with multipart form data that returns nothing (204)
490    pub fn put_multipart_blocking_no_content(
491        &self,
492        path: &str,
493        form: reqwest::blocking::multipart::Form,
494    ) -> Result<()> {
495        let url = self.build_url(path)?;
496        use reqwest::blocking::header::{HeaderMap, HeaderValue, ACCEPT};
497        let mut headers = HeaderMap::new();
498        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
499        headers.insert(
500            "User-Agent",
501            HeaderValue::from_str(&self.config.user_agent)
502                .map_err(|e| RainError::Other(anyhow::anyhow!("Invalid user agent: {e}")))?,
503        );
504
505        let response = self
506            .blocking_client
507            .put(url.as_str())
508            .headers(headers)
509            .header("Api-Key", &self.auth_config.api_key)
510            .multipart(form)
511            .send()?;
512
513        let status = response.status();
514        if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
515            Ok(())
516        } else {
517            let text = response.text()?;
518            Err(RainError::Other(anyhow::anyhow!("HTTP {status}: {text}")))
519        }
520    }
521
522    #[cfg(feature = "sync")]
523    /// Make a blocking DELETE request
524    pub fn delete_blocking(&self, path: &str) -> Result<()> {
525        let url = self.build_url(path)?;
526        let builder = self.blocking_client.delete(url.as_str());
527        let builder = crate::auth::add_auth_headers_sync(builder, &self.auth_config);
528
529        let response = builder.send()?;
530        let status = response.status();
531        if status.is_success() || status == reqwest::StatusCode::NO_CONTENT {
532            Ok(())
533        } else {
534            let text = response.text()?;
535            Err(RainError::Other(anyhow::anyhow!("HTTP {status}: {text}")))
536        }
537    }
538
539    #[cfg(feature = "async")]
540    async fn handle_response<T: DeserializeOwned>(&self, response: reqwest::Response) -> Result<T> {
541        let status = response.status();
542        let url = response.url().clone();
543        let text = response.text().await?;
544
545        // Handle 202 Accepted (typically has no body)
546        if status == reqwest::StatusCode::ACCEPTED {
547            if text.is_empty() {
548                // Try to deserialize as empty JSON object for 202
549                serde_json::from_str("{}")
550                    .or_else(|_| serde_json::from_str("null"))
551                    .map_err(|_| RainError::ValidationError("Empty response body".to_string()))
552            } else {
553                serde_json::from_str(&text).map_err(RainError::DeserializationError)
554            }
555        } else if status.is_success() {
556            if text.is_empty() {
557                // Handle 204 No Content
558                serde_json::from_str("null")
559                    .map_err(|_| RainError::ValidationError("Empty response body".to_string()))
560            } else {
561                serde_json::from_str(&text).map_err(RainError::DeserializationError)
562            }
563        } else {
564            // Try to parse as error response
565            match serde_json::from_str::<crate::error::ApiErrorResponse>(&text) {
566                Ok(api_error) => Err(RainError::ApiError {
567                    status: status.as_u16(),
568                    response: Box::new(api_error),
569                }),
570                Err(_) => Err(RainError::Other(anyhow::anyhow!(
571                    "HTTP {} from {}: {}",
572                    status,
573                    url,
574                    if text.len() > 200 {
575                        format!("{}...", &text[..200])
576                    } else {
577                        text
578                    }
579                ))),
580            }
581        }
582    }
583
584    #[cfg(feature = "sync")]
585    fn handle_blocking_response<T: DeserializeOwned>(
586        &self,
587        response: reqwest::blocking::Response,
588    ) -> Result<T> {
589        let status = response.status();
590        let text = response.text()?;
591
592        // Handle 202 Accepted (typically has no body)
593        if status == reqwest::StatusCode::ACCEPTED {
594            if text.is_empty() {
595                // Try to deserialize as empty JSON object for 202
596                serde_json::from_str("{}")
597                    .or_else(|_| serde_json::from_str("null"))
598                    .map_err(|_| RainError::ValidationError("Empty response body".to_string()))
599            } else {
600                serde_json::from_str(&text).map_err(RainError::DeserializationError)
601            }
602        } else if status.is_success() {
603            if text.is_empty() {
604                // Handle 204 No Content
605                serde_json::from_str("null")
606                    .map_err(|_| RainError::ValidationError("Empty response body".to_string()))
607            } else {
608                serde_json::from_str(&text).map_err(RainError::DeserializationError)
609            }
610        } else {
611            // Try to parse as error response
612            match serde_json::from_str::<crate::error::ApiErrorResponse>(&text) {
613                Ok(api_error) => Err(RainError::ApiError {
614                    status: status.as_u16(),
615                    response: Box::new(api_error),
616                }),
617                Err(_) => Err(RainError::Other(anyhow::anyhow!("HTTP {status}: {text}"))),
618            }
619        }
620    }
621}