style(src-tauri): cargo fmt
Pure mechanical rustfmt reformat (default config, no rustfmt.toml).
This commit is contained in:
@@ -49,7 +49,10 @@ impl Default for ClientOptions {
|
||||
/// Plain http is only for a local development site; anything else must use https.
|
||||
pub fn is_local_dev_host(host: &str) -> bool {
|
||||
let host = host.trim_end_matches('.').to_ascii_lowercase();
|
||||
host == "localhost" || host == "127.0.0.1" || host.ends_with(".localhost") || host.ends_with(".test")
|
||||
host == "localhost"
|
||||
|| host == "127.0.0.1"
|
||||
|| host.ends_with(".localhost")
|
||||
|| host.ends_with(".test")
|
||||
}
|
||||
|
||||
/// Normalises what the user typed into `scheme://host[:port][/prefix]` without a trailing slash.
|
||||
@@ -63,10 +66,15 @@ pub fn normalize_base_url(raw: &str) -> Result<String, ErpError> {
|
||||
raw.to_string()
|
||||
} else {
|
||||
let host = raw.split(['/', ':']).next().unwrap_or("");
|
||||
let scheme = if is_local_dev_host(host) { "http" } else { "https" };
|
||||
let scheme = if is_local_dev_host(host) {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
format!("{scheme}://{raw}")
|
||||
};
|
||||
let mut url = Url::parse(&with_scheme).map_err(|e| ErpError::config(format!("The ERPNext address is not valid: {e}")))?;
|
||||
let mut url = Url::parse(&with_scheme)
|
||||
.map_err(|e| ErpError::config(format!("The ERPNext address is not valid: {e}")))?;
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| ErpError::config("The ERPNext address has no host name."))?
|
||||
@@ -80,10 +88,16 @@ pub fn normalize_base_url(raw: &str) -> Result<String, ErpError> {
|
||||
));
|
||||
}
|
||||
}
|
||||
other => return Err(ErpError::config(format!("Unsupported address scheme \"{other}\"; use https://."))),
|
||||
other => {
|
||||
return Err(ErpError::config(format!(
|
||||
"Unsupported address scheme \"{other}\"; use https://."
|
||||
)))
|
||||
}
|
||||
}
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
return Err(ErpError::config("Do not put credentials in the address; use the API key and secret fields."));
|
||||
return Err(ErpError::config(
|
||||
"Do not put credentials in the address; use the API key and secret fields.",
|
||||
));
|
||||
}
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
@@ -114,7 +128,9 @@ pub struct ErpClient {
|
||||
|
||||
impl std::fmt::Debug for ErpClient {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ErpClient").field("base", &self.base.as_str()).finish_non_exhaustive()
|
||||
f.debug_struct("ErpClient")
|
||||
.field("base", &self.base.as_str())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,12 +143,18 @@ impl ErpClient {
|
||||
opts: ClientOptions,
|
||||
) -> Result<Self, ErpError> {
|
||||
ensure_crypto_provider();
|
||||
let base = Url::parse(&normalize_base_url(base_url)?).map_err(|e| ErpError::config(e.to_string()))?;
|
||||
let base = Url::parse(&normalize_base_url(base_url)?)
|
||||
.map_err(|e| ErpError::config(e.to_string()))?;
|
||||
if api_key.trim().is_empty() || !api_secret.is_set() {
|
||||
return Err(ErpError::config("Enter the API key and API secret."));
|
||||
}
|
||||
let mut auth = HeaderValue::from_str(&format!("token {}:{}", api_key.trim(), api_secret.expose()))
|
||||
.map_err(|_| ErpError::config("The API key or secret contains characters that cannot be sent."))?;
|
||||
let mut auth =
|
||||
HeaderValue::from_str(&format!("token {}:{}", api_key.trim(), api_secret.expose()))
|
||||
.map_err(|_| {
|
||||
ErpError::config(
|
||||
"The API key or secret contains characters that cannot be sent.",
|
||||
)
|
||||
})?;
|
||||
auth.set_sensitive(true);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
@@ -152,13 +174,18 @@ impl ErpClient {
|
||||
let certs = reqwest::Certificate::from_pem_bundle(extra_ca_pem.trim().as_bytes())
|
||||
.map_err(|_| ErpError::config("The extra CA certificate is not valid PEM."))?;
|
||||
if certs.is_empty() {
|
||||
return Err(ErpError::config("The extra CA certificate field holds no certificate."));
|
||||
return Err(ErpError::config(
|
||||
"The extra CA certificate field holds no certificate.",
|
||||
));
|
||||
}
|
||||
builder = builder.tls_certs_merge(certs);
|
||||
}
|
||||
let http = builder
|
||||
.build()
|
||||
.map_err(|e| ErpError::config(format!("Could not set up the HTTP client: {}", error_chain(&e))))?;
|
||||
let http = builder.build().map_err(|e| {
|
||||
ErpError::config(format!(
|
||||
"Could not set up the HTTP client: {}",
|
||||
error_chain(&e)
|
||||
))
|
||||
})?;
|
||||
Ok(ErpClient {
|
||||
http,
|
||||
base,
|
||||
@@ -168,7 +195,13 @@ impl ErpClient {
|
||||
}
|
||||
|
||||
pub fn from_config(cfg: &ErpnextConfig) -> Result<Self, ErpError> {
|
||||
Self::new(&cfg.base_url, &cfg.api_key, &cfg.api_secret, &cfg.extra_ca_pem, ClientOptions::default())
|
||||
Self::new(
|
||||
&cfg.base_url,
|
||||
&cfg.api_key,
|
||||
&cfg.api_secret,
|
||||
&cfg.extra_ca_pem,
|
||||
ClientOptions::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &str {
|
||||
@@ -184,22 +217,38 @@ impl ErpClient {
|
||||
}
|
||||
|
||||
/// GET, retried on 429, 5xx and timeouts.
|
||||
pub async fn get(&self, segments: &[&str], query: &[(&str, String)]) -> Result<Value, ErpError> {
|
||||
self.send(Method::GET, segments, query, Payload::None, true).await
|
||||
pub async fn get(
|
||||
&self,
|
||||
segments: &[&str],
|
||||
query: &[(&str, String)],
|
||||
) -> Result<Value, ErpError> {
|
||||
self.send(Method::GET, segments, query, Payload::None, true)
|
||||
.await
|
||||
}
|
||||
|
||||
/// POST with a JSON body. 429 is always retried (the server refused before doing anything). 5xx and
|
||||
/// timeouts are retried only when `idempotent` is true, because the server may have processed the
|
||||
/// request: a mirrored-name Sales Invoice is idempotent (a repeat is a 409), a series-named one is not.
|
||||
pub async fn post(&self, segments: &[&str], body: &Value, idempotent: bool) -> Result<Value, ErpError> {
|
||||
self.send(Method::POST, segments, &[], Payload::Json(body), idempotent).await
|
||||
pub async fn post(
|
||||
&self,
|
||||
segments: &[&str],
|
||||
body: &Value,
|
||||
idempotent: bool,
|
||||
) -> Result<Value, ErpError> {
|
||||
self.send(Method::POST, segments, &[], Payload::Json(body), idempotent)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Multipart POST carrying one file part named `file` (the name Frappe's `upload_file` reads; the file
|
||||
/// name travels in the part's Content-Disposition) plus plain text fields. Never retried after a 5xx or
|
||||
/// timeout, because an upload that may have landed would attach the file twice.
|
||||
pub async fn post_file(&self, segments: &[&str], upload: &Upload<'_>) -> Result<Value, ErpError> {
|
||||
self.send(Method::POST, segments, &[], Payload::Upload(upload), false).await
|
||||
pub async fn post_file(
|
||||
&self,
|
||||
segments: &[&str],
|
||||
upload: &Upload<'_>,
|
||||
) -> Result<Value, ErpError> {
|
||||
self.send(Method::POST, segments, &[], Payload::Upload(upload), false)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send(
|
||||
@@ -260,7 +309,8 @@ impl ErpClient {
|
||||
if status.is_success() {
|
||||
return self.parse_success(&text);
|
||||
}
|
||||
let retryable = status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous);
|
||||
let retryable =
|
||||
status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous);
|
||||
if retryable && can_retry {
|
||||
self.backoff(attempt, retry_after).await;
|
||||
attempt += 1;
|
||||
@@ -287,7 +337,10 @@ impl ErpClient {
|
||||
}
|
||||
|
||||
async fn backoff(&self, attempt: u32, retry_after: Option<Duration>) {
|
||||
let exp = self.opts.backoff_base.saturating_mul(1u32 << attempt.min(10));
|
||||
let exp = self
|
||||
.opts
|
||||
.backoff_base
|
||||
.saturating_mul(1u32 << attempt.min(10));
|
||||
let delay = retry_after.unwrap_or(exp).min(self.opts.max_backoff);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
@@ -311,7 +364,10 @@ impl ErpClient {
|
||||
fn map_transport(&self, e: &reqwest::Error) -> ErpError {
|
||||
let chain = error_chain(e);
|
||||
let err = if e.is_timeout() {
|
||||
ErpError::new(ErrorKind::Timeout, "The ERPNext server did not answer in time.")
|
||||
ErpError::new(
|
||||
ErrorKind::Timeout,
|
||||
"The ERPNext server did not answer in time.",
|
||||
)
|
||||
} else if e.is_connect() {
|
||||
let lower = chain.to_ascii_lowercase();
|
||||
let hint = if lower.contains("certificate") || lower.contains("unknownissuer") {
|
||||
@@ -319,9 +375,15 @@ impl ErpClient {
|
||||
} else {
|
||||
""
|
||||
};
|
||||
ErpError::new(ErrorKind::Network, format!("Could not connect to the ERPNext server ({chain}).{hint}"))
|
||||
ErpError::new(
|
||||
ErrorKind::Network,
|
||||
format!("Could not connect to the ERPNext server ({chain}).{hint}"),
|
||||
)
|
||||
} else {
|
||||
ErpError::new(ErrorKind::Network, format!("The request to ERPNext failed ({chain})."))
|
||||
ErpError::new(
|
||||
ErrorKind::Network,
|
||||
format!("The request to ERPNext failed ({chain})."),
|
||||
)
|
||||
};
|
||||
self.scrub(err)
|
||||
}
|
||||
@@ -352,9 +414,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn https_is_accepted_and_normalised() {
|
||||
assert_eq!(normalize_base_url(" https://Erp.Example.com/ ").unwrap(), "https://erp.example.com");
|
||||
assert_eq!(normalize_base_url("erp.example.com").unwrap(), "https://erp.example.com");
|
||||
assert_eq!(normalize_base_url("https://erp.example.com:8443/sub/?a=1#x").unwrap(), "https://erp.example.com:8443/sub");
|
||||
assert_eq!(
|
||||
normalize_base_url(" https://Erp.Example.com/ ").unwrap(),
|
||||
"https://erp.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_base_url("erp.example.com").unwrap(),
|
||||
"https://erp.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_base_url("https://erp.example.com:8443/sub/?a=1#x").unwrap(),
|
||||
"https://erp.example.com:8443/sub"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -370,14 +441,26 @@ mod tests {
|
||||
let url = normalize_base_url(ok).unwrap_or_else(|e| panic!("{ok}: {e}"));
|
||||
assert!(url.starts_with("http://"), "{ok} -> {url}");
|
||||
}
|
||||
for bad in ["http://erp.example.com", "http://192.168.1.10:8000", "http://localhost.evil.com", "http://notlocalhost"] {
|
||||
for bad in [
|
||||
"http://erp.example.com",
|
||||
"http://192.168.1.10:8000",
|
||||
"http://localhost.evil.com",
|
||||
"http://notlocalhost",
|
||||
] {
|
||||
assert!(normalize_base_url(bad).is_err(), "{bad} must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_schemes_credentials_and_blanks_are_refused() {
|
||||
for bad in ["ftp://erp.example.com", "file:///etc/passwd", "https://user:pw@erp.example.com", "", " ", "https://"] {
|
||||
for bad in [
|
||||
"ftp://erp.example.com",
|
||||
"file:///etc/passwd",
|
||||
"https://user:pw@erp.example.com",
|
||||
"",
|
||||
" ",
|
||||
"https://",
|
||||
] {
|
||||
assert!(normalize_base_url(bad).is_err(), "{bad:?} must be refused");
|
||||
}
|
||||
}
|
||||
@@ -391,8 +474,14 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(value["message"], "Sample@example.com");
|
||||
let raw = mock.requests.lock().unwrap()[0].to_ascii_lowercase();
|
||||
assert!(raw.starts_with("get /api/method/frappe.auth.get_logged_user "), "{raw}");
|
||||
assert!(raw.contains("authorization: token thekey:thesecret"), "{raw}");
|
||||
assert!(
|
||||
raw.starts_with("get /api/method/frappe.auth.get_logged_user "),
|
||||
"{raw}"
|
||||
);
|
||||
assert!(
|
||||
raw.contains("authorization: token thekey:thesecret"),
|
||||
"{raw}"
|
||||
);
|
||||
assert!(raw.contains("accept: application/json"), "{raw}");
|
||||
}
|
||||
|
||||
@@ -400,11 +489,19 @@ mod tests {
|
||||
async fn path_segments_are_percent_encoded_once() {
|
||||
let mock = serve(vec![reply(200, json!({ "data": [] }))]).await;
|
||||
client(&mock)
|
||||
.get(&["api", "resource", "Sales Invoice", "INV/2026-001"], &[("limit_page_length", "0".into())])
|
||||
.get(
|
||||
&["api", "resource", "Sales Invoice", "INV/2026-001"],
|
||||
&[("limit_page_length", "0".into())],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let raw = mock.requests.lock().unwrap()[0].clone();
|
||||
assert!(raw.starts_with("GET /api/resource/Sales%20Invoice/INV%2F2026-001?limit_page_length=0 "), "{raw}");
|
||||
assert!(
|
||||
raw.starts_with(
|
||||
"GET /api/resource/Sales%20Invoice/INV%2F2026-001?limit_page_length=0 "
|
||||
),
|
||||
"{raw}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -412,15 +509,25 @@ mod tests {
|
||||
let mut limited = reply(429, json!({ "message": "slow down" }));
|
||||
limited.headers.push(("Retry-After", "0".into()));
|
||||
let mock = serve(vec![limited, reply(200, json!({ "message": "ok" }))]).await;
|
||||
let value = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap();
|
||||
let value = client(&mock)
|
||||
.get(&["api", "method", "ping"], &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(value["message"], "ok");
|
||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persistent_500_exhausts_the_retries() {
|
||||
let mock = serve(vec![reply(500, json!({ "exception": "frappe.exceptions.ValidationError: boom" }))]).await;
|
||||
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
||||
let mock = serve(vec![reply(
|
||||
500,
|
||||
json!({ "exception": "frappe.exceptions.ValidationError: boom" }),
|
||||
)])
|
||||
.await;
|
||||
let err = client(&mock)
|
||||
.get(&["api", "method", "ping"], &[])
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Server);
|
||||
assert_eq!(err.message, "boom");
|
||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 4, "1 attempt + 3 retries");
|
||||
@@ -428,9 +535,17 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn validation_errors_are_not_retried() {
|
||||
let mock = serve(vec![reply(417, json!({ "_error_message": "Rate is required" }))]).await;
|
||||
let mock = serve(vec![reply(
|
||||
417,
|
||||
json!({ "_error_message": "Rate is required" }),
|
||||
)])
|
||||
.await;
|
||||
let err = client(&mock)
|
||||
.post(&["api", "resource", "Sales Invoice"], &json!({ "a": 1 }), true)
|
||||
.post(
|
||||
&["api", "resource", "Sales Invoice"],
|
||||
&json!({ "a": 1 }),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Validation);
|
||||
@@ -440,25 +555,42 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeouts_are_retried_for_reads_but_not_for_ambiguous_writes() {
|
||||
let slow = Reply { delay_ms: 2_000, ..reply(200, json!({})) };
|
||||
let slow = Reply {
|
||||
delay_ms: 2_000,
|
||||
..reply(200, json!({}))
|
||||
};
|
||||
let mock = serve(vec![slow]).await;
|
||||
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
||||
let err = client(&mock)
|
||||
.get(&["api", "method", "ping"], &[])
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Timeout);
|
||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 4);
|
||||
|
||||
let slow = Reply { delay_ms: 2_000, ..reply(200, json!({})) };
|
||||
let slow = Reply {
|
||||
delay_ms: 2_000,
|
||||
..reply(200, json!({}))
|
||||
};
|
||||
let mock = serve(vec![slow]).await;
|
||||
let err = client(&mock)
|
||||
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Timeout);
|
||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 1, "a write that may have landed is not repeated");
|
||||
assert_eq!(
|
||||
mock.hits.load(Ordering::SeqCst),
|
||||
1,
|
||||
"a write that may have landed is not repeated"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_idempotent_write_retries_429_but_not_500() {
|
||||
let mock = serve(vec![reply(429, json!({})), reply(200, json!({ "data": { "name": "X" } }))]).await;
|
||||
let mock = serve(vec![
|
||||
reply(429, json!({})),
|
||||
reply(200, json!({ "data": { "name": "X" } })),
|
||||
])
|
||||
.await;
|
||||
let value = client(&mock)
|
||||
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
|
||||
.await
|
||||
@@ -478,11 +610,20 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn redirects_are_reported_not_followed() {
|
||||
let mut moved = reply(301, json!({}));
|
||||
moved.headers.push(("Location", "https://erp.example.com/".into()));
|
||||
moved
|
||||
.headers
|
||||
.push(("Location", "https://erp.example.com/".into()));
|
||||
let mock = serve(vec![moved]).await;
|
||||
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
||||
let err = client(&mock)
|
||||
.get(&["api", "method", "ping"], &[])
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Protocol);
|
||||
assert!(err.message.contains("https://erp.example.com/"), "{}", err.message);
|
||||
assert!(
|
||||
err.message.contains("https://erp.example.com/"),
|
||||
"{}",
|
||||
err.message
|
||||
);
|
||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
@@ -491,7 +632,10 @@ mod tests {
|
||||
let mut page = reply(200, json!({}));
|
||||
page.body = "<html>login</html>".into();
|
||||
let mock = serve(vec![page]).await;
|
||||
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
||||
let err = client(&mock)
|
||||
.get(&["api", "method", "ping"], &[])
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Protocol);
|
||||
}
|
||||
|
||||
@@ -500,7 +644,14 @@ mod tests {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
let c = ErpClient::new(&format!("http://127.0.0.1:{port}"), "thekey", &Secret::new("thesecret"), "", fast_opts()).unwrap();
|
||||
let c = ErpClient::new(
|
||||
&format!("http://127.0.0.1:{port}"),
|
||||
"thekey",
|
||||
&Secret::new("thesecret"),
|
||||
"",
|
||||
fast_opts(),
|
||||
)
|
||||
.unwrap();
|
||||
let err = c.get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Network);
|
||||
assert!(!err.to_string().contains("thesecret"));
|
||||
@@ -509,9 +660,30 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn credentials_are_required_and_a_bad_ca_is_refused() {
|
||||
assert!(ErpClient::new("https://erp.example.com", "", &Secret::new("s"), "", fast_opts()).is_err());
|
||||
assert!(ErpClient::new("https://erp.example.com", "k", &Secret::default(), "", fast_opts()).is_err());
|
||||
let err = ErpClient::new("https://erp.example.com", "k", &Secret::new("s"), "not a certificate", fast_opts()).unwrap_err();
|
||||
assert!(ErpClient::new(
|
||||
"https://erp.example.com",
|
||||
"",
|
||||
&Secret::new("s"),
|
||||
"",
|
||||
fast_opts()
|
||||
)
|
||||
.is_err());
|
||||
assert!(ErpClient::new(
|
||||
"https://erp.example.com",
|
||||
"k",
|
||||
&Secret::default(),
|
||||
"",
|
||||
fast_opts()
|
||||
)
|
||||
.is_err());
|
||||
let err = ErpClient::new(
|
||||
"https://erp.example.com",
|
||||
"k",
|
||||
&Secret::new("s"),
|
||||
"not a certificate",
|
||||
fast_opts(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Config);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user