mod filter;
use anyhow::{Result, bail};
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use serde::{Serialize, de::DeserializeOwned};
use serde_json::json;
pub use erpnext_client_macro::Fieldnames;
pub use filter::Comparator;
pub use filter::FilterValue;
pub use filter::Filters;
pub use filter::IntoFilterValue;
pub struct Client {
http: reqwest::Client,
settings: Settings,
}
#[derive(Debug, Deserialize)]
struct Blank {}
pub trait Fieldnames {
fn field_names() -> &'static [&'static str];
}
impl Client {
pub fn new(s: Settings) -> Self {
Client {
http: reqwest::Client::new(),
settings: s,
}
}
pub fn from_env() -> Result<Self> {
let url = std::env::var("ERPNEXT_URL")?;
if url.ends_with("/") {
bail!("url cannot end with a /");
}
let s = Settings {
url,
key: std::env::var("ERPNEXT_KEY")?,
secret: std::env::var("ERPNEXT_SECRET")?.into(),
};
Ok(Client {
http: reqwest::Client::new(),
settings: s,
})
}
pub fn with_client(http: reqwest::Client, s: Settings) -> Self {
Self { http, settings: s }
}
#[tracing::instrument(skip(self))]
pub async fn get_doctype_by_name<T: DeserializeOwned + std::fmt::Debug>(
&self,
doctype: &str,
name: &str,
) -> Result<Option<T>> {
let url = format!(
"{}/api/resource/{}/{}",
self.settings.url,
urlencoding::encode(doctype),
urlencoding::encode(name)
);
let request = self
.http
.get(url)
.basic_auth(
&self.settings.key,
Some(self.settings.secret.expose_secret()),
)
.header("Accept", "application/json")
.build()?;
let response = self.log_http_request(request).await?;
let json: serde_json::Value = response.json().await?;
if let Some(exc_type) = json.get("exc_type") {
if exc_type == &serde_json::Value::String("DoesNotExistError".into()) {
return Ok(None);
}
}
if let Some(exception_value) = json.get("exception") {
bail!("The response contains an exception: {}", exception_value);
}
let data = json
.get("data")
.ok_or_else(|| anyhow::anyhow!("Missing 'data' field in response"))?;
let parsed_data: T = serde_json::from_value(data.clone())?;
Ok(Some(parsed_data))
}
#[tracing::instrument(skip(self, filters))]
pub async fn list_doctype<T: DeserializeOwned + std::fmt::Debug + Fieldnames>(
&self,
doctype: &str,
filters: impl Into<filter::Filters>,
page_size: Option<usize>,
page_start: Option<usize>,
) -> Result<Vec<T>> {
const DEFAULT_PAGE_SIZE: usize = 1000;
const DEFAULT_PAGE_START: usize = 0;
let fields = serde_json::to_string(T::field_names())?;
let filters = filters.into();
let filters = serde_json::to_string(&filters)?;
let url = reqwest::Url::parse_with_params(
format!("{}/api/resource/{}", self.settings.url, doctype).as_str(),
[
("fields", &fields),
("filters", &filters),
(
"limit_page_length",
&page_size.unwrap_or(DEFAULT_PAGE_SIZE).to_string(),
),
(
"limit_start",
&page_start.unwrap_or(DEFAULT_PAGE_START).to_string(),
),
],
)?;
let request = self
.http
.get(url)
.basic_auth(
&self.settings.key,
Some(self.settings.secret.expose_secret()),
)
.header("Accept", "application/json")
.build()?;
let response = self.log_http_request(request).await?;
let json: serde_json::Value = response.json().await?;
if let Some(exception_value) = json.get("exception") {
bail!("The response contains an exception: {}", exception_value);
}
let data = json
.get("data")
.ok_or_else(|| anyhow::anyhow!("Missing 'data' field in response"))?;
let parsed_data: Vec<T> = match serde_json::from_value(data.to_owned()) {
Ok(data) => data,
Err(e) => {
tracing::error!(
doctype = %doctype,
data = %data,
error = %e,
"failed parsing data"
);
bail!("failed parsing data: {}", e);
}
};
Ok(parsed_data)
}
#[tracing::instrument(skip(self))]
pub async fn update_doctype<T: Serialize + std::fmt::Debug>(
&self,
doctype: &str,
name: &str,
data: &T,
) -> Result<()> {
let url = format!("{}/api/resource/{}/{}", self.settings.url, doctype, name);
let wrapped = json!({"data":data});
let request = self
.http
.put(url)
.basic_auth(
&self.settings.key,
Some(self.settings.secret.expose_secret()),
)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&wrapped)
.build()?;
let response = self.log_http_request(request).await?;
let json: serde_json::Value = response.json().await?;
if let Some(exception_value) = json.get("exception") {
bail!("The response contains an exception: {}", exception_value);
}
Ok(())
}
#[tracing::instrument(skip(self))]
pub async fn insert_doctype<T: Serialize + std::fmt::Debug>(
&self,
doctype: &str,
data: &T,
) -> Result<()> {
let _data: Blank = self.insert_doctype_with_return(doctype, data).await?;
Ok(())
}
#[tracing::instrument(skip(self))]
pub async fn insert_doctype_with_return<
T: Serialize + std::fmt::Debug,
R: for<'de> Deserialize<'de>,
>(
&self,
doctype: &str,
data: &T,
) -> Result<R> {
let wrapped = json!({"data":data});
let url = format!("{}/api/resource/{}", self.settings.url, doctype);
let request = self
.http
.post(url)
.basic_auth(
&self.settings.key,
Some(self.settings.secret.expose_secret()),
)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&wrapped)
.build()?;
let response = self.log_http_request(request).await?;
let json: serde_json::Value = response.json().await?;
if let Some(exception_value) = json.get("exception") {
bail!("The response contains an exception: {}", exception_value);
}
if let Some(data) = json.get("data") {
let parsed: R = serde_json::from_value(data.clone())?;
return Ok(parsed);
}
bail!("The response contains now data key");
}
#[tracing::instrument(skip(self))]
pub async fn get_sales_pdf(
&self,
name: &str,
format: &str,
lang: &str,
) -> anyhow::Result<bytes::Bytes> {
let params = [
("doctype", "Sales Invoice"),
("name", name),
("format", format),
("no_letterhead", "0"),
("_lang", lang),
];
let url = reqwest::Url::parse_with_params(
format!(
"{}/api/method/frappe.utils.print_format.download_pdf",
self.settings.url
)
.as_str(),
params,
)?;
let request = self
.http
.get(url)
.basic_auth(
&self.settings.key,
Some(self.settings.secret.expose_secret()),
)
.build()?;
let response = self.log_http_request(request).await?;
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let bytes = response.bytes().await?;
if content_type.starts_with("application/json") {
let json: serde_json::Value = serde_json::from_slice(&bytes)?;
if let Some(exception_value) = json.get("exception") {
bail!("PDF generation failed: {}", exception_value);
}
}
Ok(bytes)
}
async fn log_http_request(
&self,
request: reqwest::Request,
) -> anyhow::Result<reqwest::Response> {
let start = std::time::Instant::now();
let uri = request.url().clone();
match self.http.execute(request).await {
Ok(resp) => {
let duration = start.elapsed();
let status = resp.status();
let size = resp.content_length().unwrap_or(0);
tracing::debug!(
url = %uri,
status = %status,
content_length = size,
duration = %duration.as_millis(),
"HTTP request succeeded",
);
Ok(resp)
}
Err(e) => {
tracing::error!(
"HTTP request to {} failed after {:?}: {}",
uri,
start.elapsed(),
e
);
Err(e.into())
}
}
}
}
#[derive(serde::Deserialize, Debug, Clone)]
pub struct Settings {
pub url: String,
pub key: String,
pub secret: SecretString,
}