1use 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#[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 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 pub fn base_url(&self) -> &Url {
76 &self.config.base_url
77 }
78
79 fn build_url(&self, path: &str) -> Result<Url> {
81 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 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 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 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 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 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 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 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 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 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 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}