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