// Drop-in Rust client library for the Sourdough Tracker HTTP API. // // Save this file alongside your code as `sourdough_client.rs` and add // the only two third-party dependencies it needs to your Cargo.toml: // // reqwest = { version = "0.12", features = ["blocking", "json"] } // serde_json = "1" // // (We picked `reqwest` because it is the de-facto Rust HTTP client; the // rest of the surface is `std`.) // // Then use the Client struct: // // use sourdough_client::Client; // let c = Client::new("pat_..."); // let rows = c.account_list(Default::default())?; // let fresh = c.account_create(serde_json::json!({{"name": "Example GmbH"}}))?; // // Every endpoint exposed by the HTTP API is wrapped as a method on // Client. List endpoints take ListOpts; get/update/delete endpoints // take the row id as their first argument. // // Provided as-is, with no warranty. Vendor freely; modify as needed. // Targets Rust 1.74+. // // DO NOT EDIT THIS FILE MANUALLY - re-download from the docs site. // Local edits will be overwritten by the once-per-day version check. #![allow(dead_code, non_snake_case, clippy::needless_lifetimes)] use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::sync::OnceLock; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde_json::{json, Value}; // ── Identity (substituted at generation time) ─────────────────────── pub const APP_SLUG: &str = "sourdough"; pub const APP_NAME: &str = "Sourdough Tracker"; pub const MODULE_NAME: &str = "sourdough_client"; pub const CLIENT_VERSION: &str = "0.3.13"; pub const LANGUAGE: &str = "rust"; const DEFAULT_BASE: &str = "https://sourdoughtracker.com"; /// Per-type metadata baked at generation time. Available at runtime /// when calling code needs to know the legal filters / sort columns / /// max_limit for a model without a second round-trip. pub const TYPES_JSON: &str = r####"{"log_entry":{"ops":["list","read","create","update","delete"],"create_fields":["parent_id","kind","title","body","occurred_at","rise_pct","aroma_score","bubble_activity","hydration_pct","flour_used","water_temp_c","ambient_temp_c","feeding_ratio","bake_recipe","bake_outcome","loaf_count","tags"],"update_fields":["kind","title","body","occurred_at","rise_pct","aroma_score","bubble_activity","hydration_pct","flour_used","water_temp_c","ambient_temp_c","feeding_ratio","bake_recipe","bake_outcome","loaf_count","tags"],"allowed_filters":["data__parent_id","data__kind","data__bake_outcome","status","is_archived","owned_by"],"allowed_sorts":["data__occurred_at","created_at","updated_at"],"default_sort":"data__occurred_at","max_limit":200,"fields":[{"name":"body","type":"string","max_len":8000},{"name":"kind","type":"enum","values":["feed","bake","observation","milestone","photo"]},{"name":"tags","type":"tags"},{"name":"title","type":"string","max_len":200},{"name":"rise_pct","type":"number"},{"name":"parent_id","type":"string","max_len":64,"ref":{"type":"sourdough","owned":true,"optional":false}},{"name":"flour_used","type":"string","max_len":200},{"name":"loaf_count","type":"number"},{"name":"aroma_score","type":"number"},{"name":"bake_recipe","type":"string","max_len":4000},{"name":"occurred_at","type":"string","max_len":32},{"name":"bake_outcome","type":"enum","values":["amazing","great","ok","dense","flat","undercooked","overcooked"]},{"name":"water_temp_c","type":"number"},{"name":"feeding_ratio","type":"string","max_len":32},{"name":"hydration_pct","type":"number"},{"name":"ambient_temp_c","type":"number"},{"name":"bubble_activity","type":"number"}]},"sourdough":{"ops":["list","read","create","update","delete"],"create_fields":["name","slug","source","started_at","flour_type","hydration_pct","feeding_ratio","feeding_freq_hours","ambient_temp_c","last_fed_at","retired","favorite","tags","color","notes"],"update_fields":["name","slug","source","started_at","flour_type","hydration_pct","feeding_ratio","feeding_freq_hours","ambient_temp_c","last_fed_at","retired","favorite","tags","color","notes"],"allowed_filters":["data__name","data__slug","data__flour_type","data__retired","data__favorite","data__tags","status","is_archived","owned_by"],"allowed_sorts":["created_at","updated_at","data__name","data__started_at","data__last_fed_at"],"default_sort":"data__name","max_limit":200,"fields":[{"name":"mode","type":"enum","values":["establishing","maintenance","activating","baking"]},{"name":"name","type":"string","max_len":120},{"name":"slug","type":"string","max_len":120},{"name":"tags","type":"tags"},{"name":"color","type":"string","max_len":24},{"name":"notes","type":"string","max_len":8000},{"name":"source","type":"string","max_len":200},{"name":"retired","type":"bool"},{"name":"favorite","type":"bool"},{"name":"flour_type","type":"enum","values":["white","whole_wheat","rye","spelt","einkorn","khorasan","mixed","other"]},{"name":"started_at","type":"string","max_len":32},{"name":"last_fed_at","type":"string","max_len":32},{"name":"establish_day","type":"number"},{"name":"feeding_ratio","type":"string","max_len":32},{"name":"hydration_pct","type":"number"},{"name":"ambient_temp_c","type":"number"},{"name":"computed_state","type":"string","max_len":64},{"name":"bake_target_date","type":"string","max_len":32},{"name":"feeding_freq_hours","type":"number"},{"name":"establish_started_at","type":"string","max_len":32},{"name":"establish_step_done_at","type":"string","max_len":64},{"name":"establish_next_remind_at","type":"string","max_len":64}]}}"####; // ── Configuration ────────────────────────────────────────────────── /// Standard query parameters all list endpoints accept. `filters` /// carries any additional `?key=value` pairs the type allows. #[derive(Debug, Clone, Default)] pub struct ListOpts { pub limit: Option, pub offset: Option, pub sort: Option, pub q: Option, pub filters: HashMap, } /// API client. Reuse across requests; safe for concurrent use. pub struct Client { http: reqwest::blocking::Client, base_url: String, token: std::sync::Mutex>, device_id: String, session_id: String, } #[derive(Debug)] pub enum ApiError { Http { status: u16, message: String, body: Option }, Network(String), Encoding(String), } impl std::fmt::Display for ApiError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ApiError::Http { status, message, .. } => write!(f, "HTTP {}: {}", status, message), ApiError::Network(s) => write!(f, "network error: {}", s), ApiError::Encoding(s) => write!(f, "encoding error: {}", s), } } } impl std::error::Error for ApiError {} impl Client { /// Build a new Client. Pass an empty string to fall back to /// `XCLIENT_TOKEN` from the environment. pub fn new(token: &str) -> Self { let base = std::env::var("XCLIENT_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE.to_string()); let base = base.trim_end_matches('/').to_string(); let tok = if token.is_empty() { std::env::var("XCLIENT_TOKEN").ok() } else { Some(token.to_string()) }; let http = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(30)) .build() .expect("reqwest client"); Self { http, base_url: base, token: std::sync::Mutex::new(tok), device_id: load_or_mint_device_id(), session_id: mint_uuid(), } } pub fn set_token(&self, token: Option) { let mut g = self.token.lock().unwrap(); *g = token; } pub fn token(&self) -> Option { self.token.lock().unwrap().clone() } fn user_agent(&self) -> String { format!("{}/{} (lib/{}; rust)", MODULE_NAME, CLIENT_VERSION, LANGUAGE) } pub fn request_list(&self, path: &str, opts: ListOpts) -> Result { let mut full = String::from(path); let mut sep = '?'; let mut push = |full: &mut String, sep: &mut char, k: &str, v: &str| { full.push(*sep); full.push_str(&urlencoding_encode(k)); full.push('='); full.push_str(&urlencoding_encode(v)); *sep = '&'; }; if let Some(v) = opts.limit { push(&mut full, &mut sep, "limit", &v.to_string()); } if let Some(v) = opts.offset { push(&mut full, &mut sep, "offset", &v.to_string()); } if let Some(v) = &opts.sort { push(&mut full, &mut sep, "sort", v); } if let Some(v) = &opts.q { push(&mut full, &mut sep, "q", v); } for (k, v) in &opts.filters { if v.is_null() { continue; } let s = match v { Value::String(s) => s.clone(), _ => v.to_string(), }; push(&mut full, &mut sep, k, &s); } self.request_json("GET", &full, None) } pub fn request_json(&self, method: &str, path: &str, body: Option) -> Result { self.maybe_autoupdate_once(); let url = format!("{}{}", self.base_url, path); let max_retries = 3u32; let mut last_err: Option = None; for attempt in 0..max_retries { let method_upper = method.to_uppercase(); let m = match method_upper.as_str() { "GET" => reqwest::Method::GET, "POST" => reqwest::Method::POST, "PATCH" => reqwest::Method::PATCH, "PUT" => reqwest::Method::PUT, "DELETE" => reqwest::Method::DELETE, _ => reqwest::Method::GET, }; let mut req = self.http.request(m, &url) .header("Accept", "application/json") .header("User-Agent", self.user_agent()) .header("X-Client-Channel", format!("client_{}", LANGUAGE)) .header("X-Client-Version", CLIENT_VERSION) .header("X-Analytics-Device-Id", &self.device_id) .header("X-Analytics-Session-Id", &self.session_id); if let Some(tok) = self.token() { if !tok.is_empty() { req = req.header("Authorization", format!("Bearer {}", tok)); } } if let Some(body_val) = &body { req = req.header("Content-Type", "application/json").body(body_val.to_string()); } match req.send() { Ok(resp) => { let status = resp.status().as_u16(); if let Some(fresh) = resp.headers().get("x-auth-refresh-token") { if let Ok(s) = fresh.to_str() { self.set_token(Some(s.to_string())); } } if is_retryable(status) && attempt + 1 < max_retries { let ra = resp.headers().get("Retry-After") .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()); std::thread::sleep(backoff(attempt, ra)); continue; } let body_text = resp.text().unwrap_or_default(); let parsed: Option = serde_json::from_str(&body_text).ok(); if status >= 400 { let msg = parsed.as_ref() .and_then(|v| v.get("detail").or_else(|| v.get("message")).and_then(|x| x.as_str())) .map(|s| s.to_string()) .unwrap_or_else(|| format!("HTTP {}", status)); self.emit_call_event(method, path, status, false); return Err(ApiError::Http { status, message: msg, body: parsed }); } self.emit_call_event(method, path, status, true); return Ok(parsed.unwrap_or(Value::Null)); } Err(e) => { last_err = Some(ApiError::Network(e.to_string())); if attempt + 1 < max_retries { std::thread::sleep(backoff(attempt, None)); continue; } self.emit_call_event(method, path, 0, false); } } } Err(last_err.unwrap_or(ApiError::Network("request failed".into()))) } // ── Analytics ────────────────────────────────────────────────── fn emit_call_event(&self, method: &str, path: &str, status: u16, ok: bool) { static META_SENT_ONCE: OnceLock<()> = OnceLock::new(); let include_env = META_SENT_ONCE.set(()).is_ok(); let mut meta = json!({ "channel": format!("client_{}", LANGUAGE), "client_version": CLIENT_VERSION, "module_name": MODULE_NAME, "language": LANGUAGE, "os": std::env::consts::OS, "arch": std::env::consts::ARCH, }); if include_env { meta["env"] = fingerprint(); } let evt = json!({ "type": "client.call", "ts_client": now_secs(), "meta": { "method": method.to_uppercase(), "path": path.split('?').next().unwrap_or(path), "status": status, "ok": ok, }, }); let body = json!({ "device_id": self.device_id, "session_id": self.session_id, "events": [evt], "meta": meta, }); let url = format!("{}/xapi2/analytics/challenge", self.base_url); let http = self.http.clone(); let ua = self.user_agent(); std::thread::spawn(move || { let _ = http.post(&url) .header("Content-Type", "application/json") .header("User-Agent", ua) .timeout(Duration::from_secs(4)) .body(body.to_string()) .send(); }); } // ── Auto-update ──────────────────────────────────────────────── fn maybe_autoupdate_once(&self) { static ATTEMPTED: OnceLock<()> = OnceLock::new(); if ATTEMPTED.set(()).is_err() { return; } if !autoupdate_enabled() { return; } // Source replacement on disk is intentionally a no-op - the // user is running compiled code, the .rs file is just a // record of the version they vendored. We still touch the // stamp so a future surface (UI hint, build-time check) can // tell when an update was last seen. let dir = state_dir(); if let Some(dir) = dir { let stamp = dir.join("update_check.json"); let _ = fs::write(&stamp, format!("{{\"checked_at\":{}}}", now_secs())); } } } // ── Module-level helpers ────────────────────────────────────────── fn is_retryable(status: u16) -> bool { matches!(status, 408 | 425 | 429 | 500 | 502 | 503 | 504) } fn backoff(attempt: u32, retry_after_sec: Option) -> Duration { if let Some(s) = retry_after_sec { if s >= 0.0 { return Duration::from_secs_f64(s.min(60.0)); } } let d = (1u32 << attempt.min(5)) as f64; Duration::from_secs_f64(d.min(60.0)) } fn now_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } fn state_dir() -> Option { let home = dirs_home()?; let d = home.join(format!(".{}", MODULE_NAME)); let _ = fs::create_dir_all(&d); Some(d) } /// Cross-platform `$HOME` lookup without pulling in the `dirs` crate. fn dirs_home() -> Option { #[cfg(unix)] { return std::env::var_os("HOME").map(PathBuf::from); } #[cfg(windows)] { return std::env::var_os("USERPROFILE").map(PathBuf::from); } #[allow(unreachable_code)] { None } } fn load_or_mint_device_id() -> String { if let Some(d) = state_dir() { let f = d.join("device.json"); if let Ok(raw) = fs::read_to_string(&f) { if let Ok(parsed) = serde_json::from_str::(&raw) { if let Some(s) = parsed.get("device_id").and_then(|v| v.as_str()) { if s.len() >= 32 { return s.to_string(); } } } } let id = mint_uuid(); let _ = fs::write(&f, json!({ "device_id": id }).to_string()); return id; } mint_uuid() } fn mint_uuid() -> String { let mut bytes = [0u8; 16]; let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); for (i, b) in bytes.iter_mut().enumerate() { *b = ((now.wrapping_shr((i as u32) * 7)) ^ (i as u128 * 0x9e37)) as u8; } bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; let h: String = bytes.iter().map(|b| format!("{:02x}", b)).collect(); format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32]) } fn autoupdate_enabled() -> bool { let v = std::env::var("XCLIENT_NO_AUTOUPDATE").unwrap_or_default().to_lowercase(); !(v == "1" || v == "true" || v == "yes") } fn fingerprint() -> Value { let mut out = serde_json::Map::new(); out.insert("os".into(), json!(std::env::consts::OS)); out.insert("arch".into(), json!(std::env::consts::ARCH)); out.insert("term_program".into(), json!(std::env::var("TERM_PROGRAM").unwrap_or_default())); out.insert("editor_env".into(), json!(std::env::var("EDITOR").unwrap_or_default())); out.insert("ci".into(), json!(std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok())); out.insert("claude_code".into(), json!(std::env::var("CLAUDECODE").is_ok() || std::env::var("CLAUDE_CODE_ENTRYPOINT").is_ok())); out.insert("codex".into(), json!(std::env::var("CODEX_HOME").is_ok())); let tp = std::env::var("TERM_PROGRAM").unwrap_or_default().to_lowercase(); out.insert("vscode".into(), json!(tp == "vscode" && std::env::var("CURSOR_TRACE_ID").is_err())); out.insert("cursor".into(), json!(std::env::var("CURSOR_TRACE_ID").is_ok())); out.insert("antigravity".into(), json!(std::env::var("ANTIGRAVITY_TRACE_ID").is_ok())); out.insert("jetbrains".into(), json!(tp.contains("jetbrains"))); Value::Object(out) } /// Tiny URL-encoder so we don't pull in the `urlencoding` crate. /// Encodes everything outside the unreserved set to `%HH`. fn urlencoding_encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), _ => out.push_str(&format!("%{:02X}", b)), } } out } // ── Generated per-type wrapper functions ───────────────────────── // Every model that exposes an op gets one `_` method on // Client. The runtime above does the heavy lifting; these wrappers // just pin the URL + HTTP verb. impl Client { /// List `log_entry` rows. pub fn log_entry_list(&self, opts: ListOpts) -> Result { self.request_list("/xapi2/data/log_entry", opts) } /// Fetch one `log_entry` row by id. pub fn log_entry_get(&self, id: &str) -> Result { self.request_json("GET", &format!("/xapi2/data/log_entry/{}", id), None) } /// Create a new `log_entry` row. pub fn log_entry_create(&self, data: Value) -> Result { self.request_json("POST", "/xapi2/data/log_entry", Some(data)) } /// Patch an existing `log_entry` row. pub fn log_entry_update(&self, id: &str, data: Value) -> Result { self.request_json("PATCH", &format!("/xapi2/data/log_entry/{}", id), Some(data)) } /// Delete a `log_entry` row. pub fn log_entry_delete(&self, id: &str) -> Result<(), ApiError> { self.request_json("DELETE", &format!("/xapi2/data/log_entry/{}", id), None)?; Ok(()) } /// List `sourdough` rows. pub fn sourdough_list(&self, opts: ListOpts) -> Result { self.request_list("/xapi2/data/sourdough", opts) } /// Fetch one `sourdough` row by id. pub fn sourdough_get(&self, id: &str) -> Result { self.request_json("GET", &format!("/xapi2/data/sourdough/{}", id), None) } /// Create a new `sourdough` row. pub fn sourdough_create(&self, data: Value) -> Result { self.request_json("POST", "/xapi2/data/sourdough", Some(data)) } /// Patch an existing `sourdough` row. pub fn sourdough_update(&self, id: &str, data: Value) -> Result { self.request_json("PATCH", &format!("/xapi2/data/sourdough/{}", id), Some(data)) } /// Delete a `sourdough` row. pub fn sourdough_delete(&self, id: &str) -> Result<(), ApiError> { self.request_json("DELETE", &format!("/xapi2/data/sourdough/{}", id), None)?; Ok(()) } }