//! HTTP client for a Frappe/ERPNext site. All requests run here, in Rust: a webview `fetch` from //! `tauri://localhost` would be blocked by CORS. //! //! TLS is rustls with the `ring` provider (installed once, process-wide). Roots come from the OS through //! rustls-platform-verifier; an optional extra CA PEM is merged in for self-hosted sites. use super::config::{ErpnextConfig, Secret}; use super::errors::{ErpError, ErrorKind}; use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, RETRY_AFTER}; use reqwest::{Method, Url}; use serde_json::Value; use std::sync::Once; use std::time::Duration; static CRYPTO_PROVIDER: Once = Once::new(); /// Installs the ring crypto provider for rustls. Idempotent; must run before the first client is built. pub fn ensure_crypto_provider() { CRYPTO_PROVIDER.call_once(|| { // Err means another provider is already installed, which is fine for our purposes. let _ = rustls::crypto::ring::default_provider().install_default(); }); } #[derive(Debug, Clone)] pub struct ClientOptions { pub connect_timeout: Duration, pub read_timeout: Duration, pub total_timeout: Duration, /// Retries after the first attempt. pub max_retries: u32, pub backoff_base: Duration, pub max_backoff: Duration, } impl Default for ClientOptions { fn default() -> Self { ClientOptions { connect_timeout: Duration::from_secs(10), read_timeout: Duration::from_secs(30), total_timeout: Duration::from_secs(60), max_retries: 3, backoff_base: Duration::from_millis(500), max_backoff: Duration::from_secs(8), } } } /// 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") } /// Normalises what the user typed into `scheme://host[:port][/prefix]` without a trailing slash. /// A missing scheme means https, except for local development hosts, which get http. pub fn normalize_base_url(raw: &str) -> Result { let raw = raw.trim(); if raw.is_empty() { return Err(ErpError::config("The ERPNext address is empty.")); } let with_scheme = if raw.contains("://") { raw.to_string() } else { let host = raw.split(['/', ':']).next().unwrap_or(""); 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 host = url .host_str() .ok_or_else(|| ErpError::config("The ERPNext address has no host name."))? .to_string(); match url.scheme() { "https" => {} "http" => { if !is_local_dev_host(&host) { return Err(ErpError::config( "Use an https:// address. Plain http is only allowed for localhost, 127.0.0.1, *.localhost and *.test.", )); } } 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.", )); } url.set_query(None); url.set_fragment(None); Ok(url.as_str().trim_end_matches('/').to_string()) } /// A file to upload through a multipart POST. pub struct Upload<'a> { pub file_name: &'a str, pub mime: &'a str, pub bytes: &'a [u8], pub fields: &'a [(&'a str, String)], } enum Payload<'a> { None, Json(&'a Value), Upload(&'a Upload<'a>), } pub struct ErpClient { http: reqwest::Client, base: Url, opts: ClientOptions, /// Strings scrubbed from every error message. secrets: Vec, } 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() } } impl ErpClient { pub fn new( base_url: &str, api_key: &str, api_secret: &Secret, extra_ca_pem: &str, opts: ClientOptions, ) -> Result { ensure_crypto_provider(); 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.", ) })?; auth.set_sensitive(true); let mut headers = HeaderMap::new(); headers.insert(AUTHORIZATION, auth); // Frappe answers `Accept: text/*` with HTML; always ask for JSON. headers.insert(ACCEPT, HeaderValue::from_static("application/json")); let mut builder = reqwest::Client::builder() .default_headers(headers) .user_agent(concat!("Voiced/", env!("CARGO_PKG_VERSION"))) .connect_timeout(opts.connect_timeout) .read_timeout(opts.read_timeout) .timeout(opts.total_timeout) // A redirect drops the body or the auth header; surface it instead of guessing. .redirect(reqwest::redirect::Policy::none()); if !extra_ca_pem.trim().is_empty() { 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.", )); } 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) )) })?; Ok(ErpClient { http, base, opts, secrets: vec![api_secret.expose().to_string(), api_key.trim().to_string()], }) } pub fn from_config(cfg: &ErpnextConfig) -> Result { Self::new( &cfg.base_url, &cfg.api_key, &cfg.api_secret, &cfg.extra_ca_pem, ClientOptions::default(), ) } pub fn base_url(&self) -> &str { self.base.as_str().trim_end_matches('/') } fn url(&self, segments: &[&str]) -> Url { let mut url = self.base.clone(); if let Ok(mut path) = url.path_segments_mut() { path.pop_if_empty().extend(segments); } url } /// GET, retried on 429, 5xx and timeouts. pub async fn get( &self, segments: &[&str], query: &[(&str, String)], ) -> Result { 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 { 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 { self.send(Method::POST, segments, &[], Payload::Upload(upload), false) .await } async fn send( &self, method: Method, segments: &[&str], query: &[(&str, String)], payload: Payload<'_>, retry_ambiguous: bool, ) -> Result { let mut attempt: u32 = 0; loop { let mut req = self.http.request(method.clone(), self.url(segments)); if !query.is_empty() { req = req.query(query); } match &payload { Payload::None => {} Payload::Json(b) => req = req.json(*b), Payload::Upload(u) => { let part = reqwest::multipart::Part::bytes(u.bytes.to_vec()) .file_name(u.file_name.to_string()) .mime_str(u.mime) .map_err(|e| ErpError::config(format!("Bad upload type: {e}")))?; let mut form = reqwest::multipart::Form::new(); for (k, v) in u.fields { form = form.text(k.to_string(), v.clone()); } req = req.multipart(form.part("file", part)); } } let can_retry = attempt < self.opts.max_retries; match req.send().await { Ok(resp) => { let status = resp.status(); let retry_after = resp .headers() .get(RETRY_AFTER) .and_then(|v| v.to_str().ok()) .and_then(|v| v.trim().parse::().ok()) .map(Duration::from_secs); let location = resp .headers() .get(reqwest::header::LOCATION) .and_then(|v| v.to_str().ok()) .map(str::to_string); let text = match resp.text().await { Ok(t) => t, Err(e) => { if e.is_timeout() && retry_ambiguous && can_retry { self.backoff(attempt, None).await; attempt += 1; continue; } return Err(self.map_transport(&e)); } }; if status.is_success() { return self.parse_success(&text); } let retryable = status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous); if retryable && can_retry { self.backoff(attempt, retry_after).await; attempt += 1; continue; } if status.is_redirection() { let to = location.unwrap_or_else(|| "another address".to_string()); return Err(self.scrub(ErpError::protocol(format!( "The server redirected the request to {to}. Use the final address (usually https://) in the ERPNext address field." )))); } return Err(self.scrub(ErpError::from_response(status.as_u16(), &text))); } Err(e) => { if e.is_timeout() && retry_ambiguous && can_retry { self.backoff(attempt, None).await; attempt += 1; continue; } return Err(self.map_transport(&e)); } } } } async fn backoff(&self, attempt: u32, retry_after: Option) { 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; } fn parse_success(&self, text: &str) -> Result { if text.trim().is_empty() { return Ok(Value::Null); } serde_json::from_str(text).map_err(|_| { ErpError::protocol( "The server did not return JSON. Check the address: it should be the root of your ERPNext site.", ) }) } fn scrub(&self, err: ErpError) -> ErpError { let secrets: Vec<&str> = self.secrets.iter().map(String::as_str).collect(); err.redacted(&secrets) } 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.", ) } else if e.is_connect() { let lower = chain.to_ascii_lowercase(); let hint = if lower.contains("certificate") || lower.contains("unknownissuer") { " The certificate is not trusted: paste the site's CA certificate into the extra CA field." } else { "" }; 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})."), ) }; self.scrub(err) } } /// `a: b: c` for an error and its sources. fn error_chain(e: &dyn std::error::Error) -> String { let mut parts = vec![e.to_string()]; let mut source = e.source(); while let Some(s) = source { let text = s.to_string(); if parts.last() != Some(&text) { parts.push(text); } source = s.source(); } parts.join(": ") } #[cfg(test)] mod tests { use super::*; use crate::integrations::erpnext::testutil::{client, fast_opts, reply, serve, Reply}; use serde_json::json; use std::sync::atomic::Ordering; // ---- URL rules ---- #[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" ); } #[test] fn http_is_only_for_local_development_hosts() { for ok in [ "http://localhost:8000", "http://127.0.0.1:8080", "http://mysite.localhost:8000", "http://erp.test", "localhost:8000", "127.0.0.1:8000", ] { 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", ] { 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://", ] { assert!(normalize_base_url(bad).is_err(), "{bad:?} must be refused"); } } #[tokio::test] async fn sends_token_auth_and_a_json_accept_header() { let mock = serve(vec![reply(200, json!({ "message": "Sample@example.com" }))]).await; let value = client(&mock) .get(&["api", "method", "frappe.auth.get_logged_user"], &[]) .await .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.contains("accept: application/json"), "{raw}"); } #[tokio::test] 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())], ) .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}" ); } #[tokio::test] async fn retries_429_then_succeeds() { 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(); 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(); assert_eq!(err.kind, ErrorKind::Server); assert_eq!(err.message, "boom"); assert_eq!(mock.hits.load(Ordering::SeqCst), 4, "1 attempt + 3 retries"); } #[tokio::test] async fn validation_errors_are_not_retried() { 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, ) .await .unwrap_err(); assert_eq!(err.kind, ErrorKind::Validation); assert_eq!(err.message, "Rate is required"); assert_eq!(mock.hits.load(Ordering::SeqCst), 1); } #[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 mock = serve(vec![slow]).await; 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 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" ); } #[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 value = client(&mock) .post(&["api", "resource", "Sales Invoice"], &json!({}), false) .await .unwrap(); assert_eq!(value["data"]["name"], "X"); assert_eq!(mock.hits.load(Ordering::SeqCst), 2); let mock = serve(vec![reply(500, json!({}))]).await; let err = client(&mock) .post(&["api", "resource", "Sales Invoice"], &json!({}), false) .await .unwrap_err(); assert_eq!(err.kind, ErrorKind::Server); assert_eq!(mock.hits.load(Ordering::SeqCst), 1); } #[tokio::test] async fn redirects_are_reported_not_followed() { let mut moved = reply(301, json!({})); 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(); assert_eq!(err.kind, ErrorKind::Protocol); assert!( err.message.contains("https://erp.example.com/"), "{}", err.message ); assert_eq!(mock.hits.load(Ordering::SeqCst), 1); } #[tokio::test] async fn html_success_page_is_a_protocol_error() { let mut page = reply(200, json!({})); page.body = "login".into(); let mock = serve(vec![page]).await; let err = client(&mock) .get(&["api", "method", "ping"], &[]) .await .unwrap_err(); assert_eq!(err.kind, ErrorKind::Protocol); } #[tokio::test] async fn refused_connection_is_a_network_error_without_the_secret() { 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 err = c.get(&["api", "method", "ping"], &[]).await.unwrap_err(); assert_eq!(err.kind, ErrorKind::Network); assert!(!err.to_string().contains("thesecret")); assert!(!format!("{c:?}").contains("thesecret")); } #[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_eq!(err.kind, ErrorKind::Config); } }