use reqwest::header::{self, HeaderMap, HeaderValue};
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::constants::API_BASE_URL;
use crate::error::{ApiErrorBody, DhanError, Result};
pub(crate) fn required_path_segment(name: &str, value: &str) -> Result<String> {
if value.trim().is_empty() {
return Err(DhanError::InvalidArgument(format!(
"{name} must not be empty"
)));
}
if matches!(value, "." | "..") {
return Err(DhanError::InvalidArgument(format!(
"{name} must not be a path-navigation segment"
)));
}
Ok(percent_encode_component(value))
}
pub(crate) fn required_query_value(name: &str, value: &str) -> Result<String> {
required_path_segment(name, value)
}
fn percent_encode_component(value: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
encoded.push(char::from(byte));
} else {
encoded.push('%');
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
encoded.push(char::from(HEX[usize::from(byte & 0x0F)]));
}
}
encoded
}
#[derive(Clone)]
pub struct DhanClient {
http: reqwest::Client,
client_id: String,
access_token: String,
base_url: String,
}
impl std::fmt::Debug for DhanClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DhanClient")
.field("client_id", &"[REDACTED]")
.field("access_token", &"[REDACTED]")
.field("base_url", &self.base_url)
.finish_non_exhaustive()
}
}
impl DhanClient {
pub fn new(client_id: impl Into<String>, access_token: impl Into<String>) -> Self {
Self::with_base_url(client_id, access_token, API_BASE_URL)
}
pub fn with_base_url(
client_id: impl Into<String>,
access_token: impl Into<String>,
base_url: impl Into<String>,
) -> Self {
let client_id = client_id.into();
let access_token = access_token.into();
let base_url = base_url.into();
Self::try_with_base_url(client_id.clone(), access_token.clone(), base_url.clone())
.unwrap_or_else(|_| {
Self::with_unchecked_credentials(client_id, access_token, base_url)
})
}
pub fn try_new(client_id: impl Into<String>, access_token: impl Into<String>) -> Result<Self> {
Self::try_with_base_url(client_id, access_token, API_BASE_URL)
}
pub fn try_with_base_url(
client_id: impl Into<String>,
access_token: impl Into<String>,
base_url: impl Into<String>,
) -> Result<Self> {
let client_id = client_id.into();
let access_token = access_token.into();
Self::validate_credentials(&client_id, &access_token)?;
Ok(Self::with_unchecked_credentials(
client_id,
access_token,
base_url.into(),
))
}
fn with_unchecked_credentials(
client_id: String,
access_token: String,
base_url: String,
) -> Self {
let http = reqwest::Client::builder()
.default_headers(Self::default_headers())
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("failed to build reqwest client");
Self {
http,
client_id,
access_token,
base_url: base_url.trim_end_matches('/').to_owned(),
}
}
pub fn http(&self) -> &reqwest::Client {
&self.http
}
pub fn client_id(&self) -> &str {
&self.client_id
}
pub fn access_token(&self) -> &str {
&self.access_token
}
pub fn set_access_token(&mut self, token: impl Into<String>) {
self.access_token = token.into();
}
pub fn try_set_access_token(&mut self, token: impl Into<String>) -> Result<()> {
let token = token.into();
HeaderValue::from_str(&token)?;
self.access_token = token;
Ok(())
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn get<R: DeserializeOwned>(&self, path: &str) -> Result<R> {
let url = self.url(path);
tracing::debug!(%url, "GET");
let resp = self
.http
.get(&url)
.headers(self.auth_headers()?)
.send()
.await?;
self.handle_response(resp).await
}
pub async fn post<B: Serialize, R: DeserializeOwned>(&self, path: &str, body: &B) -> Result<R> {
let url = self.url(path);
tracing::debug!(%url, "POST");
let resp = self
.http
.post(&url)
.headers(self.auth_headers()?)
.json(body)
.send()
.await?;
self.handle_response(resp).await
}
pub async fn post_without_body<R: DeserializeOwned>(&self, path: &str) -> Result<R> {
let url = self.url(path);
tracing::debug!(%url, "POST");
let resp = self
.http
.post(&url)
.headers(self.auth_headers()?)
.send()
.await?;
self.handle_response(resp).await
}
pub async fn put<B: Serialize, R: DeserializeOwned>(&self, path: &str, body: &B) -> Result<R> {
let url = self.url(path);
tracing::debug!(%url, "PUT");
let resp = self
.http
.put(&url)
.headers(self.auth_headers()?)
.json(body)
.send()
.await?;
self.handle_response(resp).await
}
pub async fn delete<R: DeserializeOwned>(&self, path: &str) -> Result<R> {
let url = self.url(path);
tracing::debug!(%url, "DELETE");
let resp = self
.http
.delete(&url)
.headers(self.auth_headers()?)
.send()
.await?;
self.handle_response(resp).await
}
pub async fn delete_no_content(&self, path: &str) -> Result<()> {
let url = self.url(path);
tracing::debug!(%url, "DELETE (no content)");
let resp = self
.http
.delete(&url)
.headers(self.auth_headers()?)
.send()
.await?;
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let body = resp
.text()
.await
.map_err(|source| DhanError::ResponseBody { status, source })?;
Err(self.parse_error_body(status, &body))
}
}
pub async fn get_no_content(&self, path: &str) -> Result<()> {
let url = self.url(path);
tracing::debug!(%url, "GET (no content)");
let resp = self
.http
.get(&url)
.headers(self.auth_headers()?)
.send()
.await?;
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let body = resp
.text()
.await
.map_err(|source| DhanError::ResponseBody { status, source })?;
Err(self.parse_error_body(status, &body))
}
}
pub async fn post_no_content<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
let url = self.url(path);
tracing::debug!(%url, "POST (no content)");
let resp = self
.http
.post(&url)
.headers(self.auth_headers()?)
.json(body)
.send()
.await?;
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let body = resp
.text()
.await
.map_err(|source| DhanError::ResponseBody { status, source })?;
Err(self.parse_error_body(status, &body))
}
}
fn url(&self, path: &str) -> String {
if path.starts_with('/') {
format!("{}{}", self.base_url, path)
} else {
format!("{}/{}", self.base_url, path)
}
}
fn default_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
headers.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
headers
}
fn auth_headers(&self) -> Result<HeaderMap> {
let mut headers = HeaderMap::with_capacity(2);
let mut token = HeaderValue::from_str(&self.access_token)?;
token.set_sensitive(true);
let mut client_id = HeaderValue::from_str(&self.client_id)?;
client_id.set_sensitive(true);
headers.insert("access-token", token);
headers.insert("client-id", client_id);
Ok(headers)
}
fn validate_credentials(client_id: &str, access_token: &str) -> Result<()> {
HeaderValue::from_str(client_id)?;
HeaderValue::from_str(access_token)?;
Ok(())
}
async fn handle_response<R: DeserializeOwned>(&self, resp: reqwest::Response) -> Result<R> {
let status = resp.status();
let bytes = resp
.bytes()
.await
.map_err(|source| DhanError::ResponseBody { status, source })?;
if status.is_success() {
serde_json::from_slice(&bytes).map_err(DhanError::Json)
} else {
let body = String::from_utf8_lossy(&bytes);
Err(self.parse_error_body(status, &body))
}
}
pub(crate) fn parse_error_body(&self, status: reqwest::StatusCode, body: &str) -> DhanError {
if let Ok(api_err) = serde_json::from_str::<ApiErrorBody>(body) {
if api_err.error_code.is_some() || api_err.error_message.is_some() {
return DhanError::Api(api_err);
}
}
DhanError::HttpStatus {
status,
body: body.to_owned(),
}
}
}