Skip to main content

dhan_rs/
client.rs

1//! Core HTTP client for the DhanHQ REST API v2.
2//!
3//! The [`DhanClient`] struct is the main entry point for interacting with all
4//! DhanHQ REST API endpoints. It wraps [`reqwest::Client`] with authentication
5//! headers and provides typed `get`, `post`, `put`, and `delete` methods.
6//!
7//! API endpoint methods are added to `DhanClient` via `impl` blocks in the
8//! [`crate::api`] module.
9
10use 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
17/// Validate a required dynamic path component and encode it as one URL path
18/// segment.  Callers must use this rather than interpolating user input into a
19/// route: an order ID such as `a/b` is an ID, not two path segments.
20pub(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    // `byte_serialize` leaves RFC 3986 unreserved characters alone. A whole
27    // dot segment is special to URL parsers, so reject it rather than relying
28    // on percent-encoding that a parser might normalize before transmission.
29    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
37/// Validate and percent-encode a required query value.
38///
39/// This uses percent encoding rather than form `+` escaping so generated
40/// routes remain unambiguous in logs, proxies, and request-target tests.
41pub(crate) fn required_query_value(name: &str, value: &str) -> Result<String> {
42    required_path_segment(name, value)
43}
44
45/// Percent-encode a URL component without applying HTML-form `+` escaping.
46fn 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/// Core HTTP client for the DhanHQ REST API v2.
62///
63/// Wraps [`reqwest::Client`] and injects the required authentication headers
64/// into every request. Header values are validated at request time so public
65/// credential input cannot panic during client construction or token rotation.
66///
67/// # Example
68///
69/// ```no_run
70/// use dhan_rs::client::DhanClient;
71///
72/// # #[tokio::main]
73/// # async fn main() -> dhan_rs::error::Result<()> {
74/// let client = DhanClient::new("1000000001", "your-access-token");
75/// // client.get::<MyResponse>("/v2/orders").await?;
76/// # Ok(())
77/// # }
78/// ```
79#[derive(Clone)]
80pub struct DhanClient {
81    http: reqwest::Client,
82    /// The Dhan client ID (user-specific identification).
83    client_id: String,
84    /// JWT access token.
85    access_token: String,
86    /// Base URL for REST API requests (defaults to [`API_BASE_URL`]).
87    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    /// Create a new `DhanClient` with the given client ID and access token.
102    ///
103    /// Uses the default API base URL (`https://api.dhan.co`).
104    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    /// Create a new `DhanClient` pointing at a custom base URL.
109    ///
110    /// Useful for testing against a sandbox or mock server.
111    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                // Keep the established infallible constructor source-compatible.
122                // Invalid credentials are converted to typed errors when a request
123                // is attempted, rather than panicking during construction.
124                Self::with_unchecked_credentials(client_id, access_token, base_url)
125            })
126    }
127
128    /// Fallible variant of [`Self::new`] that validates credential header
129    /// values at construction time.
130    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    /// Fallible variant of [`Self::with_base_url`] that validates credential
135    /// header values at construction time.
136    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    /// Returns a reference to the underlying `reqwest::Client`.
171    pub fn http(&self) -> &reqwest::Client {
172        &self.http
173    }
174
175    /// Returns the Dhan client ID.
176    pub fn client_id(&self) -> &str {
177        &self.client_id
178    }
179
180    /// Returns the current access token.
181    pub fn access_token(&self) -> &str {
182        &self.access_token
183    }
184
185    /// Replace the access token (e.g. after renewal).
186    pub fn set_access_token(&mut self, token: impl Into<String>) {
187        self.access_token = token.into();
188    }
189
190    /// Validate and replace the access token without deferring invalid-header
191    /// errors to a later request.
192    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    /// Returns the base URL.
200    pub fn base_url(&self) -> &str {
201        &self.base_url
202    }
203
204    // -----------------------------------------------------------------------
205    // Generic HTTP helpers
206    // -----------------------------------------------------------------------
207
208    /// Perform a GET request and deserialize the JSON response.
209    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    /// Perform a POST request with a JSON body and deserialize the response.
224    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    /// Perform a POST request without a request body and deserialize the
240    /// successful JSON response.
241    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    /// Perform a PUT request with a JSON body and deserialize the response.
256    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    /// Perform a DELETE request and deserialize the JSON response.
272    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    /// Perform a DELETE request that returns no body (expects 202 Accepted).
287    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    /// Perform a GET request that returns no body (expects 202 Accepted).
311    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    /// Perform a POST request that returns no body (expects 202 Accepted).
335    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    // -----------------------------------------------------------------------
360    // Private helpers
361    // -----------------------------------------------------------------------
362
363    /// Build the full URL from a path segment.
364    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    /// Default headers applied to every request.
373    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    /// Per-request auth headers. Credentials are validated here so legacy
384    /// infallible constructors cannot panic on public input.
385    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    /// Read a response, returning either the deserialized body or a `DhanError`.
403    ///
404    /// Uses `bytes()` + `serde_json::from_slice()` to avoid the overhead of
405    /// UTF-8 validation that `text()` + `from_str()` would incur.
406    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            // Error path: parse as string for the error body
417            let body = String::from_utf8_lossy(&bytes);
418            Err(self.parse_error_body(status, &body))
419        }
420    }
421
422    /// Try to parse the API's JSON error structure; fall back to a raw HTTP
423    /// status error.
424    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}