1use reqwest::header::{self, HeaderMap, HeaderValue};
11use serde::Serialize;
12use serde::de::DeserializeOwned;
13
14use crate::constants::API_BASE_URL;
15use crate::error::{ApiErrorBody, DhanError, Result};
16
17pub(crate) fn required_path_segment(name: &str, value: &str) -> Result<String> {
21 if value.trim().is_empty() {
22 return Err(DhanError::InvalidArgument(format!(
23 "{name} must not be empty"
24 )));
25 }
26 if matches!(value, "." | "..") {
30 return Err(DhanError::InvalidArgument(format!(
31 "{name} must not be a path-navigation segment"
32 )));
33 }
34 Ok(percent_encode_component(value))
35}
36
37pub(crate) fn required_query_value(name: &str, value: &str) -> Result<String> {
42 required_path_segment(name, value)
43}
44
45fn percent_encode_component(value: &str) -> String {
47 const HEX: &[u8; 16] = b"0123456789ABCDEF";
48 let mut encoded = String::with_capacity(value.len());
49 for byte in value.bytes() {
50 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
51 encoded.push(char::from(byte));
52 } else {
53 encoded.push('%');
54 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
55 encoded.push(char::from(HEX[usize::from(byte & 0x0F)]));
56 }
57 }
58 encoded
59}
60
61#[derive(Clone)]
80pub struct DhanClient {
81 http: reqwest::Client,
82 client_id: String,
84 access_token: String,
86 base_url: String,
88}
89
90impl std::fmt::Debug for DhanClient {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.debug_struct("DhanClient")
93 .field("client_id", &"[REDACTED]")
94 .field("access_token", &"[REDACTED]")
95 .field("base_url", &self.base_url)
96 .finish_non_exhaustive()
97 }
98}
99
100impl DhanClient {
101 pub fn new(client_id: impl Into<String>, access_token: impl Into<String>) -> Self {
105 Self::with_base_url(client_id, access_token, API_BASE_URL)
106 }
107
108 pub fn with_base_url(
112 client_id: impl Into<String>,
113 access_token: impl Into<String>,
114 base_url: impl Into<String>,
115 ) -> Self {
116 let client_id = client_id.into();
117 let access_token = access_token.into();
118 let base_url = base_url.into();
119 Self::try_with_base_url(client_id.clone(), access_token.clone(), base_url.clone())
120 .unwrap_or_else(|_| {
121 Self::with_unchecked_credentials(client_id, access_token, base_url)
125 })
126 }
127
128 pub fn try_new(client_id: impl Into<String>, access_token: impl Into<String>) -> Result<Self> {
131 Self::try_with_base_url(client_id, access_token, API_BASE_URL)
132 }
133
134 pub fn try_with_base_url(
137 client_id: impl Into<String>,
138 access_token: impl Into<String>,
139 base_url: impl Into<String>,
140 ) -> Result<Self> {
141 let client_id = client_id.into();
142 let access_token = access_token.into();
143 Self::validate_credentials(&client_id, &access_token)?;
144 Ok(Self::with_unchecked_credentials(
145 client_id,
146 access_token,
147 base_url.into(),
148 ))
149 }
150
151 fn with_unchecked_credentials(
152 client_id: String,
153 access_token: String,
154 base_url: String,
155 ) -> Self {
156 let http = reqwest::Client::builder()
157 .default_headers(Self::default_headers())
158 .redirect(reqwest::redirect::Policy::none())
159 .build()
160 .expect("failed to build reqwest client");
161
162 Self {
163 http,
164 client_id,
165 access_token,
166 base_url: base_url.trim_end_matches('/').to_owned(),
167 }
168 }
169
170 pub fn http(&self) -> &reqwest::Client {
172 &self.http
173 }
174
175 pub fn client_id(&self) -> &str {
177 &self.client_id
178 }
179
180 pub fn access_token(&self) -> &str {
182 &self.access_token
183 }
184
185 pub fn set_access_token(&mut self, token: impl Into<String>) {
187 self.access_token = token.into();
188 }
189
190 pub fn try_set_access_token(&mut self, token: impl Into<String>) -> Result<()> {
193 let token = token.into();
194 HeaderValue::from_str(&token)?;
195 self.access_token = token;
196 Ok(())
197 }
198
199 pub fn base_url(&self) -> &str {
201 &self.base_url
202 }
203
204 pub async fn get<R: DeserializeOwned>(&self, path: &str) -> Result<R> {
210 let url = self.url(path);
211 tracing::debug!(%url, "GET");
212
213 let resp = self
214 .http
215 .get(&url)
216 .headers(self.auth_headers()?)
217 .send()
218 .await?;
219
220 self.handle_response(resp).await
221 }
222
223 pub async fn post<B: Serialize, R: DeserializeOwned>(&self, path: &str, body: &B) -> Result<R> {
225 let url = self.url(path);
226 tracing::debug!(%url, "POST");
227
228 let resp = self
229 .http
230 .post(&url)
231 .headers(self.auth_headers()?)
232 .json(body)
233 .send()
234 .await?;
235
236 self.handle_response(resp).await
237 }
238
239 pub async fn post_without_body<R: DeserializeOwned>(&self, path: &str) -> Result<R> {
242 let url = self.url(path);
243 tracing::debug!(%url, "POST");
244
245 let resp = self
246 .http
247 .post(&url)
248 .headers(self.auth_headers()?)
249 .send()
250 .await?;
251
252 self.handle_response(resp).await
253 }
254
255 pub async fn put<B: Serialize, R: DeserializeOwned>(&self, path: &str, body: &B) -> Result<R> {
257 let url = self.url(path);
258 tracing::debug!(%url, "PUT");
259
260 let resp = self
261 .http
262 .put(&url)
263 .headers(self.auth_headers()?)
264 .json(body)
265 .send()
266 .await?;
267
268 self.handle_response(resp).await
269 }
270
271 pub async fn delete<R: DeserializeOwned>(&self, path: &str) -> Result<R> {
273 let url = self.url(path);
274 tracing::debug!(%url, "DELETE");
275
276 let resp = self
277 .http
278 .delete(&url)
279 .headers(self.auth_headers()?)
280 .send()
281 .await?;
282
283 self.handle_response(resp).await
284 }
285
286 pub async fn delete_no_content(&self, path: &str) -> Result<()> {
288 let url = self.url(path);
289 tracing::debug!(%url, "DELETE (no content)");
290
291 let resp = self
292 .http
293 .delete(&url)
294 .headers(self.auth_headers()?)
295 .send()
296 .await?;
297
298 let status = resp.status();
299 if status.is_success() {
300 Ok(())
301 } else {
302 let body = resp
303 .text()
304 .await
305 .map_err(|source| DhanError::ResponseBody { status, source })?;
306 Err(self.parse_error_body(status, &body))
307 }
308 }
309
310 pub async fn get_no_content(&self, path: &str) -> Result<()> {
312 let url = self.url(path);
313 tracing::debug!(%url, "GET (no content)");
314
315 let resp = self
316 .http
317 .get(&url)
318 .headers(self.auth_headers()?)
319 .send()
320 .await?;
321
322 let status = resp.status();
323 if status.is_success() {
324 Ok(())
325 } else {
326 let body = resp
327 .text()
328 .await
329 .map_err(|source| DhanError::ResponseBody { status, source })?;
330 Err(self.parse_error_body(status, &body))
331 }
332 }
333
334 pub async fn post_no_content<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
336 let url = self.url(path);
337 tracing::debug!(%url, "POST (no content)");
338
339 let resp = self
340 .http
341 .post(&url)
342 .headers(self.auth_headers()?)
343 .json(body)
344 .send()
345 .await?;
346
347 let status = resp.status();
348 if status.is_success() {
349 Ok(())
350 } else {
351 let body = resp
352 .text()
353 .await
354 .map_err(|source| DhanError::ResponseBody { status, source })?;
355 Err(self.parse_error_body(status, &body))
356 }
357 }
358
359 fn url(&self, path: &str) -> String {
365 if path.starts_with('/') {
366 format!("{}{}", self.base_url, path)
367 } else {
368 format!("{}/{}", self.base_url, path)
369 }
370 }
371
372 fn default_headers() -> HeaderMap {
374 let mut headers = HeaderMap::new();
375 headers.insert(
376 header::CONTENT_TYPE,
377 HeaderValue::from_static("application/json"),
378 );
379 headers.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
380 headers
381 }
382
383 fn auth_headers(&self) -> Result<HeaderMap> {
386 let mut headers = HeaderMap::with_capacity(2);
387 let mut token = HeaderValue::from_str(&self.access_token)?;
388 token.set_sensitive(true);
389 let mut client_id = HeaderValue::from_str(&self.client_id)?;
390 client_id.set_sensitive(true);
391 headers.insert("access-token", token);
392 headers.insert("client-id", client_id);
393 Ok(headers)
394 }
395
396 fn validate_credentials(client_id: &str, access_token: &str) -> Result<()> {
397 HeaderValue::from_str(client_id)?;
398 HeaderValue::from_str(access_token)?;
399 Ok(())
400 }
401
402 async fn handle_response<R: DeserializeOwned>(&self, resp: reqwest::Response) -> Result<R> {
407 let status = resp.status();
408 let bytes = resp
409 .bytes()
410 .await
411 .map_err(|source| DhanError::ResponseBody { status, source })?;
412
413 if status.is_success() {
414 serde_json::from_slice(&bytes).map_err(DhanError::Json)
415 } else {
416 let body = String::from_utf8_lossy(&bytes);
418 Err(self.parse_error_body(status, &body))
419 }
420 }
421
422 pub(crate) fn parse_error_body(&self, status: reqwest::StatusCode, body: &str) -> DhanError {
425 if let Ok(api_err) = serde_json::from_str::<ApiErrorBody>(body) {
426 if api_err.error_code.is_some() || api_err.error_message.is_some() {
427 return DhanError::Api(api_err);
428 }
429 }
430 DhanError::HttpStatus {
431 status,
432 body: body.to_owned(),
433 }
434 }
435}