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/// Core HTTP client for the DhanHQ REST API v2.
18///
19/// Wraps [`reqwest::Client`] and injects the required authentication headers
20/// into every request. Auth header values are cached at construction time to
21/// avoid per-request allocation.
22///
23/// # Example
24///
25/// ```no_run
26/// use dhan_rs::client::DhanClient;
27///
28/// # #[tokio::main]
29/// # async fn main() -> dhan_rs::error::Result<()> {
30/// let client = DhanClient::new("1000000001", "your-access-token");
31/// // client.get::<MyResponse>("/v2/orders").await?;
32/// # Ok(())
33/// # }
34/// ```
35#[derive(Debug, Clone)]
36pub struct DhanClient {
37    http: reqwest::Client,
38    /// The Dhan client ID (user-specific identification).
39    client_id: String,
40    /// JWT access token.
41    access_token: String,
42    /// Base URL for REST API requests (defaults to [`API_BASE_URL`]).
43    base_url: String,
44    /// Pre-built auth header values, cached to avoid per-request allocation.
45    auth_header_token: HeaderValue,
46    auth_header_client_id: HeaderValue,
47}
48
49impl DhanClient {
50    /// Create a new `DhanClient` with the given client ID and access token.
51    ///
52    /// Uses the default API base URL (`https://api.dhan.co`).
53    pub fn new(client_id: impl Into<String>, access_token: impl Into<String>) -> Self {
54        Self::with_base_url(client_id, access_token, API_BASE_URL)
55    }
56
57    /// Create a new `DhanClient` pointing at a custom base URL.
58    ///
59    /// Useful for testing against a sandbox or mock server.
60    pub fn with_base_url(
61        client_id: impl Into<String>,
62        access_token: impl Into<String>,
63        base_url: impl Into<String>,
64    ) -> Self {
65        let http = reqwest::Client::builder()
66            .default_headers(Self::default_headers())
67            .build()
68            .expect("failed to build reqwest client");
69
70        let access_token = access_token.into();
71        let client_id = client_id.into();
72
73        let auth_header_token = HeaderValue::from_str(&access_token)
74            .expect("access token contains invalid header characters");
75        let auth_header_client_id = HeaderValue::from_str(&client_id)
76            .expect("client id contains invalid header characters");
77
78        Self {
79            http,
80            client_id,
81            access_token,
82            base_url: base_url.into().trim_end_matches('/').to_owned(),
83            auth_header_token,
84            auth_header_client_id,
85        }
86    }
87
88    /// Returns a reference to the underlying `reqwest::Client`.
89    pub fn http(&self) -> &reqwest::Client {
90        &self.http
91    }
92
93    /// Returns the Dhan client ID.
94    pub fn client_id(&self) -> &str {
95        &self.client_id
96    }
97
98    /// Returns the current access token.
99    pub fn access_token(&self) -> &str {
100        &self.access_token
101    }
102
103    /// Replace the access token (e.g. after renewal).
104    pub fn set_access_token(&mut self, token: impl Into<String>) {
105        self.access_token = token.into();
106        self.auth_header_token = HeaderValue::from_str(&self.access_token)
107            .expect("access token contains invalid header characters");
108    }
109
110    /// Returns the base URL.
111    pub fn base_url(&self) -> &str {
112        &self.base_url
113    }
114
115    // -----------------------------------------------------------------------
116    // Generic HTTP helpers
117    // -----------------------------------------------------------------------
118
119    /// Perform a GET request and deserialize the JSON response.
120    pub async fn get<R: DeserializeOwned>(&self, path: &str) -> Result<R> {
121        let url = self.url(path);
122        tracing::debug!(%url, "GET");
123
124        let resp = self
125            .http
126            .get(&url)
127            .headers(self.auth_headers())
128            .send()
129            .await?;
130
131        self.handle_response(resp).await
132    }
133
134    /// Perform a POST request with a JSON body and deserialize the response.
135    pub async fn post<B: Serialize, R: DeserializeOwned>(&self, path: &str, body: &B) -> Result<R> {
136        let url = self.url(path);
137        tracing::debug!(%url, "POST");
138
139        let resp = self
140            .http
141            .post(&url)
142            .headers(self.auth_headers())
143            .json(body)
144            .send()
145            .await?;
146
147        self.handle_response(resp).await
148    }
149
150    /// Perform a PUT request with a JSON body and deserialize the response.
151    pub async fn put<B: Serialize, R: DeserializeOwned>(&self, path: &str, body: &B) -> Result<R> {
152        let url = self.url(path);
153        tracing::debug!(%url, "PUT");
154
155        let resp = self
156            .http
157            .put(&url)
158            .headers(self.auth_headers())
159            .json(body)
160            .send()
161            .await?;
162
163        self.handle_response(resp).await
164    }
165
166    /// Perform a DELETE request and deserialize the JSON response.
167    pub async fn delete<R: DeserializeOwned>(&self, path: &str) -> Result<R> {
168        let url = self.url(path);
169        tracing::debug!(%url, "DELETE");
170
171        let resp = self
172            .http
173            .delete(&url)
174            .headers(self.auth_headers())
175            .send()
176            .await?;
177
178        self.handle_response(resp).await
179    }
180
181    /// Perform a DELETE request that returns no body (expects 202 Accepted).
182    pub async fn delete_no_content(&self, path: &str) -> Result<()> {
183        let url = self.url(path);
184        tracing::debug!(%url, "DELETE (no content)");
185
186        let resp = self
187            .http
188            .delete(&url)
189            .headers(self.auth_headers())
190            .send()
191            .await?;
192
193        let status = resp.status();
194        if status.is_success() {
195            Ok(())
196        } else {
197            let body = resp.text().await.unwrap_or_default();
198            Err(self.parse_error_body(status, &body))
199        }
200    }
201
202    /// Perform a GET request that returns no body (expects 202 Accepted).
203    pub async fn get_no_content(&self, path: &str) -> Result<()> {
204        let url = self.url(path);
205        tracing::debug!(%url, "GET (no content)");
206
207        let resp = self
208            .http
209            .get(&url)
210            .headers(self.auth_headers())
211            .send()
212            .await?;
213
214        let status = resp.status();
215        if status.is_success() {
216            Ok(())
217        } else {
218            let body = resp.text().await.unwrap_or_default();
219            Err(self.parse_error_body(status, &body))
220        }
221    }
222
223    /// Perform a POST request that returns no body (expects 202 Accepted).
224    pub async fn post_no_content<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
225        let url = self.url(path);
226        tracing::debug!(%url, "POST (no content)");
227
228        let resp = self
229            .http
230            .post(&url)
231            .headers(self.auth_headers())
232            .json(body)
233            .send()
234            .await?;
235
236        let status = resp.status();
237        if status.is_success() {
238            Ok(())
239        } else {
240            let body = resp.text().await.unwrap_or_default();
241            Err(self.parse_error_body(status, &body))
242        }
243    }
244
245    // -----------------------------------------------------------------------
246    // Private helpers
247    // -----------------------------------------------------------------------
248
249    /// Build the full URL from a path segment.
250    fn url(&self, path: &str) -> String {
251        if path.starts_with('/') {
252            format!("{}{}", self.base_url, path)
253        } else {
254            format!("{}/{}", self.base_url, path)
255        }
256    }
257
258    /// Default headers applied to every request.
259    fn default_headers() -> HeaderMap {
260        let mut headers = HeaderMap::new();
261        headers.insert(
262            header::CONTENT_TYPE,
263            HeaderValue::from_static("application/json"),
264        );
265        headers.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
266        headers
267    }
268
269    /// Per-request auth headers. Uses cached [`HeaderValue`]s — only the
270    /// [`HeaderMap`] container is allocated per call (no string parsing).
271    fn auth_headers(&self) -> HeaderMap {
272        let mut headers = HeaderMap::with_capacity(2);
273        headers.insert("access-token", self.auth_header_token.clone());
274        headers.insert("client-id", self.auth_header_client_id.clone());
275        headers
276    }
277
278    /// Read a response, returning either the deserialized body or a `DhanError`.
279    ///
280    /// Uses `bytes()` + `serde_json::from_slice()` to avoid the overhead of
281    /// UTF-8 validation that `text()` + `from_str()` would incur.
282    async fn handle_response<R: DeserializeOwned>(&self, resp: reqwest::Response) -> Result<R> {
283        let status = resp.status();
284        let bytes = resp.bytes().await.unwrap_or_default();
285
286        if status.is_success() {
287            serde_json::from_slice(&bytes).map_err(DhanError::Json)
288        } else {
289            // Error path: parse as string for the error body
290            let body = String::from_utf8_lossy(&bytes);
291            Err(self.parse_error_body(status, &body))
292        }
293    }
294
295    /// Try to parse the API's JSON error structure; fall back to a raw HTTP
296    /// status error.
297    pub(crate) fn parse_error_body(&self, status: reqwest::StatusCode, body: &str) -> DhanError {
298        if let Ok(api_err) = serde_json::from_str::<ApiErrorBody>(body) {
299            if api_err.error_code.is_some() || api_err.error_message.is_some() {
300                return DhanError::Api(api_err);
301            }
302        }
303        DhanError::HttpStatus {
304            status,
305            body: body.to_owned(),
306        }
307    }
308}