noah_sdk/
client.rs

1//! Main HTTP client for the Noah SDK
2//!
3//! This module provides the core HTTP client for making requests to the Noah 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 JWT signing and 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 noah_sdk::{NoahClient, Config, Environment, AuthConfig};
19//!
20//! # #[cfg(feature = "async")]
21//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
22//! let config = Config::new(Environment::Sandbox);
23//! let auth = AuthConfig::with_api_key("your-api-key".to_string());
24//! let client = NoahClient::new(config, auth)?;
25//!
26//! // Use async methods
27//! let balances = client.get_balances(None, None).await?;
28//! # Ok(())
29//! # }
30//! ```
31//!
32//! ## Blocking Client
33//!
34//! ```no_run
35//! use noah_sdk::{NoahClient, Config, Environment, AuthConfig};
36//!
37//! # #[cfg(feature = "sync")]
38//! let config = Config::new(Environment::Sandbox);
39//! let auth = AuthConfig::with_api_key("your-api-key".to_string());
40//! let client = NoahClient::new(config, auth)?;
41//!
42//! // Use blocking methods
43//! let balances = client.get_balances_blocking(None, None)?;
44//! ```
45
46use crate::auth::AuthConfig;
47use crate::config::Config;
48use crate::error::{NoahError, Result};
49use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, CONTENT_TYPE};
50use serde::de::DeserializeOwned;
51use url::Url;
52
53/// Main client for interacting with the Noah API
54///
55/// This client provides methods to interact with all Noah API endpoints.
56/// It handles authentication, request building, and response parsing automatically.
57///
58/// # Thread Safety
59///
60/// The client is `Clone` and can be shared across threads safely.
61///
62/// # Examples
63///
64/// ```no_run
65/// use noah_sdk::{NoahClient, Config, Environment, AuthConfig};
66///
67/// # #[cfg(feature = "async")]
68/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
69/// let config = Config::new(Environment::Sandbox);
70/// let auth = AuthConfig::with_api_key("your-api-key".to_string());
71/// let client = NoahClient::new(config, auth)?;
72/// # Ok(())
73/// # }
74/// ```
75#[derive(Clone)]
76pub struct NoahClient {
77    config: Config,
78    auth_config: AuthConfig,
79    #[cfg(feature = "async")]
80    client: reqwest::Client,
81    #[cfg(feature = "sync")]
82    blocking_client: reqwest::blocking::Client,
83}
84
85impl NoahClient {
86    /// Create a new client with the given configuration
87    ///
88    /// # Arguments
89    ///
90    /// * `config` - Client configuration (environment, timeout, etc.)
91    /// * `auth_config` - Authentication configuration (API key or JWT secret)
92    ///
93    /// # Returns
94    ///
95    /// Returns a new `NoahClient` instance ready to make API requests.
96    ///
97    /// # Errors
98    ///
99    /// This function will return an error if:
100    /// - The HTTP client cannot be created
101    /// - The user agent string is invalid
102    ///
103    /// # Examples
104    ///
105    /// ```no_run
106    /// use noah_sdk::{NoahClient, Config, Environment, AuthConfig};
107    ///
108    /// # #[cfg(feature = "async")]
109    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
110    /// // Using API key authentication
111    /// let config = Config::new(Environment::Sandbox);
112    /// let auth = AuthConfig::with_api_key("your-api-key".to_string());
113    /// let client = NoahClient::new(config, auth)?;
114    ///
115    /// // Using JWT signing
116    /// let config = Config::new(Environment::Production);
117    /// let auth = AuthConfig::with_secret_key("your-secret-key".to_string());
118    /// let client = NoahClient::new(config, auth)?;
119    ///
120    /// // Using both
121    /// let auth = AuthConfig::with_both(
122    ///     "your-api-key".to_string(),
123    ///     "your-secret-key".to_string()
124    /// );
125    /// let client = NoahClient::new(config, auth)?;
126    /// # Ok(())
127    /// # }
128    /// ```
129    pub fn new(config: Config, auth_config: AuthConfig) -> Result<Self> {
130        #[cfg(feature = "async")]
131        let client = {
132            let mut headers = HeaderMap::new();
133            headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
134            headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
135            headers.insert(
136                "User-Agent",
137                HeaderValue::from_str(&config.user_agent)
138                    .map_err(|e| NoahError::Other(anyhow::anyhow!("Invalid user agent: {e}")))?,
139            );
140
141            reqwest::Client::builder()
142                .default_headers(headers)
143                .timeout(std::time::Duration::from_secs(config.timeout_secs))
144                .redirect(reqwest::redirect::Policy::limited(10))
145                .build()
146                .map_err(NoahError::HttpError)?
147        };
148
149        #[cfg(feature = "sync")]
150        let blocking_client = {
151            let mut headers = HeaderMap::new();
152            headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
153            headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
154            headers.insert(
155                "User-Agent",
156                HeaderValue::from_str(&config.user_agent)
157                    .map_err(|e| NoahError::Other(anyhow::anyhow!("Invalid user agent: {e}")))?,
158            );
159
160            reqwest::blocking::Client::builder()
161                .default_headers(headers)
162                .timeout(std::time::Duration::from_secs(config.timeout_secs))
163                .redirect(reqwest::redirect::Policy::limited(10))
164                .build()
165                .map_err(|e| {
166                    NoahError::Other(anyhow::anyhow!("Failed to create blocking client: {e}"))
167                })?
168        };
169
170        Ok(Self {
171            config,
172            auth_config,
173            #[cfg(feature = "async")]
174            client,
175            #[cfg(feature = "sync")]
176            blocking_client,
177        })
178    }
179
180    /// Get the base URL for API requests
181    ///
182    /// Returns the base URL that all API requests will be made against.
183    ///
184    /// # Examples
185    ///
186    /// ```no_run
187    /// use noah_sdk::{NoahClient, Config, Environment, AuthConfig};
188    ///
189    /// # #[cfg(feature = "async")]
190    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
191    /// let config = Config::new(Environment::Sandbox);
192    /// let auth = AuthConfig::with_api_key("your-api-key".to_string());
193    /// let client = NoahClient::new(config, auth)?;
194    ///
195    /// let base_url = client.base_url();
196    /// println!("API base URL: {}", base_url);
197    /// # Ok(())
198    /// # }
199    /// ```
200    pub fn base_url(&self) -> &Url {
201        &self.config.base_url
202    }
203
204    /// Build a full URL from a path
205    fn build_url(&self, path: &str) -> Result<Url> {
206        // If path starts with /, we need to preserve the base URL's path
207        let path_to_join = path.strip_prefix('/').unwrap_or(path);
208
209        let mut url = self.config.base_url.clone();
210        url.path_segments_mut()
211            .map_err(|_| NoahError::Other(anyhow::anyhow!("Cannot be a base URL")))?
212            .pop_if_empty()
213            .extend(path_to_join.split('/').filter(|s| !s.is_empty()));
214
215        Ok(url)
216    }
217
218    #[cfg(feature = "async")]
219    /// Make an async GET request
220    pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
221        let url = self.build_url(path)?;
222        let mut builder = self.client.get(url.as_str());
223
224        builder = crate::auth::add_auth_headers_async(
225            builder,
226            &self.auth_config,
227            "GET",
228            url.path(),
229            None,
230        )?;
231
232        let response = builder.send().await?;
233        self.handle_response(response).await
234    }
235
236    #[cfg(feature = "async")]
237    /// Make an async POST request
238    pub async fn post<T: DeserializeOwned, B: serde::Serialize>(
239        &self,
240        path: &str,
241        body: &B,
242    ) -> Result<T> {
243        let url = self.build_url(path)?;
244        let body_bytes = serde_json::to_vec(body)?;
245        let mut builder = self.client.post(url.as_str()).body(body_bytes.clone());
246
247        builder = crate::auth::add_auth_headers_async(
248            builder,
249            &self.auth_config,
250            "POST",
251            url.path(),
252            Some(&body_bytes),
253        )?;
254
255        let response = builder.send().await?;
256        self.handle_response(response).await
257    }
258
259    #[cfg(feature = "async")]
260    /// Make an async PUT request
261    pub async fn put<T: DeserializeOwned, B: serde::Serialize>(
262        &self,
263        path: &str,
264        body: &B,
265    ) -> Result<T> {
266        let url = self.build_url(path)?;
267        let body_bytes = serde_json::to_vec(body)?;
268        let mut builder = self.client.put(url.as_str()).body(body_bytes.clone());
269
270        builder = crate::auth::add_auth_headers_async(
271            builder,
272            &self.auth_config,
273            "PUT",
274            url.path(),
275            Some(&body_bytes),
276        )?;
277
278        let response = builder.send().await?;
279        self.handle_response(response).await
280    }
281
282    #[cfg(feature = "sync")]
283    /// Make a blocking GET request
284    pub fn get_blocking<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
285        let url = self.build_url(path)?;
286        let mut builder = self.blocking_client.get(url.as_str());
287
288        builder = crate::auth::add_auth_headers_sync(
289            builder,
290            &self.auth_config,
291            "GET",
292            url.path(),
293            None,
294        )?;
295
296        let response = builder.send()?;
297        self.handle_blocking_response(response)
298    }
299
300    #[cfg(feature = "sync")]
301    /// Make a blocking POST request
302    pub fn post_blocking<T: DeserializeOwned, B: serde::Serialize>(
303        &self,
304        path: &str,
305        body: &B,
306    ) -> Result<T> {
307        let url = self.build_url(path)?;
308        let body_bytes = serde_json::to_vec(body)?;
309        let mut builder = self
310            .blocking_client
311            .post(url.as_str())
312            .body(body_bytes.clone());
313
314        builder = crate::auth::add_auth_headers_sync(
315            builder,
316            &self.auth_config,
317            "POST",
318            url.path(),
319            Some(&body_bytes),
320        )?;
321
322        let response = builder.send()?;
323        self.handle_blocking_response(response)
324    }
325
326    #[cfg(feature = "sync")]
327    /// Make a blocking PUT request
328    pub fn put_blocking<T: DeserializeOwned, B: serde::Serialize>(
329        &self,
330        path: &str,
331        body: &B,
332    ) -> Result<T> {
333        let url = self.build_url(path)?;
334        let body_bytes = serde_json::to_vec(body)?;
335        let mut builder = self
336            .blocking_client
337            .put(url.as_str())
338            .body(body_bytes.clone());
339
340        builder = crate::auth::add_auth_headers_sync(
341            builder,
342            &self.auth_config,
343            "PUT",
344            url.path(),
345            Some(&body_bytes),
346        )?;
347
348        let response = builder.send()?;
349        self.handle_blocking_response(response)
350    }
351
352    #[cfg(feature = "async")]
353    async fn handle_response<T: DeserializeOwned>(&self, response: reqwest::Response) -> Result<T> {
354        let status = response.status();
355        let url = response.url().clone();
356        let text = response.text().await?;
357
358        if status.is_success() {
359            if text.is_empty() {
360                // Handle 204 No Content
361                serde_json::from_str("null")
362                    .map_err(|_| NoahError::ValidationError("Empty response body".to_string()))
363            } else {
364                serde_json::from_str(&text).map_err(NoahError::DeserializationError)
365            }
366        } else {
367            // Try to parse as error response
368            match serde_json::from_str::<crate::error::ApiErrorResponse>(&text) {
369                Ok(api_error) => Err(NoahError::ApiError(Box::new(api_error))),
370                Err(_) => Err(NoahError::Other(anyhow::anyhow!(
371                    "HTTP {} from {}: {}",
372                    status,
373                    url,
374                    if text.len() > 200 {
375                        format!("{}...", &text[..200])
376                    } else {
377                        text
378                    }
379                ))),
380            }
381        }
382    }
383
384    #[cfg(feature = "sync")]
385    fn handle_blocking_response<T: DeserializeOwned>(
386        &self,
387        response: reqwest::blocking::Response,
388    ) -> Result<T> {
389        let status = response.status();
390        let text = response.text()?;
391
392        if status.is_success() {
393            if text.is_empty() {
394                // Handle 204 No Content
395                serde_json::from_str("null")
396                    .map_err(|_| NoahError::ValidationError("Empty response body".to_string()))
397            } else {
398                serde_json::from_str(&text).map_err(NoahError::DeserializationError)
399            }
400        } else {
401            // Try to parse as error response
402            match serde_json::from_str::<crate::error::ApiErrorResponse>(&text) {
403                Ok(api_error) => Err(NoahError::ApiError(Box::new(api_error))),
404                Err(_) => Err(NoahError::Other(anyhow::anyhow!("HTTP {status}: {text}"))),
405            }
406        }
407    }
408}