noah_sdk/
client.rs

1//! Main HTTP client for the Noah SDK
2
3use crate::auth::AuthConfig;
4use crate::config::Config;
5use crate::error::{NoahError, Result};
6use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, CONTENT_TYPE};
7use serde::de::DeserializeOwned;
8use url::Url;
9
10/// Main client for interacting with the Noah API
11#[derive(Clone)]
12pub struct NoahClient {
13    config: Config,
14    auth_config: AuthConfig,
15    #[cfg(feature = "async")]
16    client: reqwest::Client,
17    #[cfg(feature = "sync")]
18    blocking_client: reqwest::blocking::Client,
19}
20
21impl NoahClient {
22    /// Create a new client with the given configuration
23    pub fn new(config: Config, auth_config: AuthConfig) -> Result<Self> {
24        #[cfg(feature = "async")]
25        let client = {
26            let mut headers = HeaderMap::new();
27            headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
28            headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
29            headers.insert(
30                "User-Agent",
31                HeaderValue::from_str(&config.user_agent)
32                    .map_err(|e| NoahError::Other(anyhow::anyhow!("Invalid user agent: {e}")))?,
33            );
34
35            reqwest::Client::builder()
36                .default_headers(headers)
37                .timeout(std::time::Duration::from_secs(config.timeout_secs))
38                .redirect(reqwest::redirect::Policy::limited(10))
39                .build()
40                .map_err(NoahError::HttpError)?
41        };
42
43        #[cfg(feature = "sync")]
44        let blocking_client = {
45            let mut headers = HeaderMap::new();
46            headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
47            headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
48            headers.insert(
49                "User-Agent",
50                HeaderValue::from_str(&config.user_agent)
51                    .map_err(|e| NoahError::Other(anyhow::anyhow!("Invalid user agent: {e}")))?,
52            );
53
54            reqwest::blocking::Client::builder()
55                .default_headers(headers)
56                .timeout(std::time::Duration::from_secs(config.timeout_secs))
57                .redirect(reqwest::redirect::Policy::limited(10))
58                .build()
59                .map_err(|e| {
60                    NoahError::Other(anyhow::anyhow!("Failed to create blocking client: {e}"))
61                })?
62        };
63
64        Ok(Self {
65            config,
66            auth_config,
67            #[cfg(feature = "async")]
68            client,
69            #[cfg(feature = "sync")]
70            blocking_client,
71        })
72    }
73
74    /// Get the base URL
75    pub fn base_url(&self) -> &Url {
76        &self.config.base_url
77    }
78
79    /// Build a full URL from a path
80    fn build_url(&self, path: &str) -> Result<Url> {
81        // If path starts with /, we need to preserve the base URL's path
82        let path_to_join = path.strip_prefix('/').unwrap_or(path);
83
84        let mut url = self.config.base_url.clone();
85        url.path_segments_mut()
86            .map_err(|_| NoahError::Other(anyhow::anyhow!("Cannot be a base URL")))?
87            .pop_if_empty()
88            .extend(path_to_join.split('/').filter(|s| !s.is_empty()));
89
90        Ok(url)
91    }
92
93    #[cfg(feature = "async")]
94    /// Make an async GET request
95    pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
96        let url = self.build_url(path)?;
97        let mut builder = self.client.get(url.as_str());
98
99        builder = crate::auth::add_auth_headers_async(
100            builder,
101            &self.auth_config,
102            "GET",
103            url.path(),
104            None,
105        )?;
106
107        let response = builder.send().await?;
108        self.handle_response(response).await
109    }
110
111    #[cfg(feature = "async")]
112    /// Make an async POST request
113    pub async fn post<T: DeserializeOwned, B: serde::Serialize>(
114        &self,
115        path: &str,
116        body: &B,
117    ) -> Result<T> {
118        let url = self.build_url(path)?;
119        let body_bytes = serde_json::to_vec(body)?;
120        let mut builder = self.client.post(url.as_str()).body(body_bytes.clone());
121
122        builder = crate::auth::add_auth_headers_async(
123            builder,
124            &self.auth_config,
125            "POST",
126            url.path(),
127            Some(&body_bytes),
128        )?;
129
130        let response = builder.send().await?;
131        self.handle_response(response).await
132    }
133
134    #[cfg(feature = "async")]
135    /// Make an async PUT request
136    pub async fn put<T: DeserializeOwned, B: serde::Serialize>(
137        &self,
138        path: &str,
139        body: &B,
140    ) -> Result<T> {
141        let url = self.build_url(path)?;
142        let body_bytes = serde_json::to_vec(body)?;
143        let mut builder = self.client.put(url.as_str()).body(body_bytes.clone());
144
145        builder = crate::auth::add_auth_headers_async(
146            builder,
147            &self.auth_config,
148            "PUT",
149            url.path(),
150            Some(&body_bytes),
151        )?;
152
153        let response = builder.send().await?;
154        self.handle_response(response).await
155    }
156
157    #[cfg(feature = "sync")]
158    /// Make a blocking GET request
159    pub fn get_blocking<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
160        let url = self.build_url(path)?;
161        let mut builder = self.blocking_client.get(url.as_str());
162
163        builder = crate::auth::add_auth_headers_sync(
164            builder,
165            &self.auth_config,
166            "GET",
167            url.path(),
168            None,
169        )?;
170
171        let response = builder.send()?;
172        self.handle_blocking_response(response)
173    }
174
175    #[cfg(feature = "sync")]
176    /// Make a blocking POST request
177    pub fn post_blocking<T: DeserializeOwned, B: serde::Serialize>(
178        &self,
179        path: &str,
180        body: &B,
181    ) -> Result<T> {
182        let url = self.build_url(path)?;
183        let body_bytes = serde_json::to_vec(body)?;
184        let mut builder = self
185            .blocking_client
186            .post(url.as_str())
187            .body(body_bytes.clone());
188
189        builder = crate::auth::add_auth_headers_sync(
190            builder,
191            &self.auth_config,
192            "POST",
193            url.path(),
194            Some(&body_bytes),
195        )?;
196
197        let response = builder.send()?;
198        self.handle_blocking_response(response)
199    }
200
201    #[cfg(feature = "sync")]
202    /// Make a blocking PUT request
203    pub fn put_blocking<T: DeserializeOwned, B: serde::Serialize>(
204        &self,
205        path: &str,
206        body: &B,
207    ) -> Result<T> {
208        let url = self.build_url(path)?;
209        let body_bytes = serde_json::to_vec(body)?;
210        let mut builder = self
211            .blocking_client
212            .put(url.as_str())
213            .body(body_bytes.clone());
214
215        builder = crate::auth::add_auth_headers_sync(
216            builder,
217            &self.auth_config,
218            "PUT",
219            url.path(),
220            Some(&body_bytes),
221        )?;
222
223        let response = builder.send()?;
224        self.handle_blocking_response(response)
225    }
226
227    #[cfg(feature = "async")]
228    async fn handle_response<T: DeserializeOwned>(&self, response: reqwest::Response) -> Result<T> {
229        let status = response.status();
230        let url = response.url().clone();
231        let text = response.text().await?;
232
233        if status.is_success() {
234            if text.is_empty() {
235                // Handle 204 No Content
236                serde_json::from_str("null")
237                    .map_err(|_| NoahError::ValidationError("Empty response body".to_string()))
238            } else {
239                serde_json::from_str(&text).map_err(NoahError::DeserializationError)
240            }
241        } else {
242            // Try to parse as error response
243            match serde_json::from_str::<crate::error::ApiErrorResponse>(&text) {
244                Ok(api_error) => Err(NoahError::ApiError(Box::new(api_error))),
245                Err(_) => Err(NoahError::Other(anyhow::anyhow!(
246                    "HTTP {} from {}: {}",
247                    status,
248                    url,
249                    if text.len() > 200 {
250                        format!("{}...", &text[..200])
251                    } else {
252                        text
253                    }
254                ))),
255            }
256        }
257    }
258
259    #[cfg(feature = "sync")]
260    fn handle_blocking_response<T: DeserializeOwned>(
261        &self,
262        response: reqwest::blocking::Response,
263    ) -> Result<T> {
264        let status = response.status();
265        let text = response.text()?;
266
267        if status.is_success() {
268            if text.is_empty() {
269                // Handle 204 No Content
270                serde_json::from_str("null")
271                    .map_err(|_| NoahError::ValidationError("Empty response body".to_string()))
272            } else {
273                serde_json::from_str(&text).map_err(NoahError::DeserializationError)
274            }
275        } else {
276            // Try to parse as error response
277            match serde_json::from_str::<crate::error::ApiErrorResponse>(&text) {
278                Ok(api_error) => Err(NoahError::ApiError(Box::new(api_error))),
279                Err(_) => Err(NoahError::Other(anyhow::anyhow!("HTTP {status}: {text}"))),
280            }
281        }
282    }
283}