// Drop-in Rust client library for the KI BMS HTTP API. // // Save this file alongside your code as `ats_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 ats_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 = "ats"; pub const APP_NAME: &str = "KI BMS"; pub const MODULE_NAME: &str = "ats_client"; pub const CLIENT_VERSION: &str = "0.3.13"; pub const LANGUAGE: &str = "rust"; const DEFAULT_BASE: &str = "https://www.ki-bewerber-management.de"; /// 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####"{"application":{"ops":["list","read","create","update","delete"],"create_fields":["job_id","candidate_id","stage","previous_stage","position","applied_at","last_stage_at","source_id","source_label","cover_letter","cv_blob_id","cv_url","answers","fit_score","fit_reasoning","fit_flags","fit_computed_at","rejected_reason","rejected_note","tags"],"update_fields":["stage","previous_stage","position","applied_at","last_stage_at","source_id","source_label","cover_letter","cv_blob_id","cv_url","answers","fit_score","fit_reasoning","fit_flags","fit_computed_at","rejected_reason","rejected_note","tags"],"allowed_filters":["data__job_id","data__candidate_id","data__stage","data__source_id","data__rejected_reason","data__is_archived","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","updated_at","data__applied_at","data__fit_score","data__position","data__last_stage_at"],"default_sort":"data__position","max_limit":500,"fields":[{"name":"tags","type":"tags"},{"name":"stage","type":"enum","values":["new","review","screening","interview","offer","hired","rejected","talent_pool"]},{"name":"cv_url","type":"url","max_len":2048},{"name":"job_id","type":"string","max_len":64,"ref":{"type":"job","owned":false,"optional":false}},{"name":"answers","type":"list"},{"name":"position","type":"number"},{"name":"fit_flags","type":"tags"},{"name":"fit_score","type":"number"},{"name":"source_id","type":"string","max_len":64,"ref":{"type":"source","owned":false,"optional":false}},{"name":"applied_at","type":"string","max_len":32},{"name":"cv_blob_id","type":"string","max_len":64},{"name":"candidate_id","type":"string","max_len":64,"ref":{"type":"candidate","owned":false,"optional":false}},{"name":"cover_letter","type":"string","max_len":16000},{"name":"source_label","type":"string","max_len":200},{"name":"fit_reasoning","type":"string","max_len":4000},{"name":"last_stage_at","type":"string","max_len":32},{"name":"rejected_note","type":"string","max_len":2000},{"name":"previous_stage","type":"string","max_len":32},{"name":"fit_computed_at","type":"string","max_len":32},{"name":"rejected_reason","type":"enum","values":["not_qualified","salary_mismatch","location_mismatch","culture_mismatch","withdrew","ghosted","filled_internally","duplicate","other"]}]},"application_note":{"ops":["list","read","create","update","delete"],"create_fields":["body","pinned","private","parent_kind","parent_id"],"update_fields":["body","pinned","private"],"allowed_filters":["data__parent_id","data__parent_kind","data__pinned","data__private","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","updated_at"],"default_sort":"created_at","max_limit":200,"fields":[{"name":"body","type":"string","max_len":8000},{"name":"pinned","type":"bool"},{"name":"private","type":"bool"},{"name":"parent_id","type":"string","max_len":64},{"name":"parent_kind","type":"enum","values":["candidate","application","job"]}]},"candidate":{"ops":["list","read","create","update","delete"],"create_fields":["name","first_name","last_name","salutation","pronouns","email","phone","city","country","current_company","current_role","years_experience","available_from","salary_expectation","currency","linkedin","github","portfolio","cv_url","cv_blob_id","avatar_blob_id","summary","skills","languages","tags","source_id","source_label","pool_status","gdpr_consent","gdpr_consent_at","gdpr_retention_until","preferred_locale","last_touched_at","color"],"update_fields":["name","first_name","last_name","salutation","pronouns","email","phone","city","country","current_company","current_role","years_experience","available_from","salary_expectation","currency","linkedin","github","portfolio","cv_url","cv_blob_id","avatar_blob_id","summary","skills","languages","tags","source_id","source_label","pool_status","gdpr_consent","gdpr_consent_at","gdpr_retention_until","preferred_locale","last_touched_at","color"],"allowed_filters":["data__email","data__name","data__location","data__country","data__source_id","data__tags","data__skills","data__pool_status","data__gdpr_consent","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","updated_at","data__name","data__last_touched_at"],"default_sort":"created_at","max_limit":200,"fields":[{"name":"city","type":"string","max_len":120},{"name":"name","type":"string","max_len":200},{"name":"tags","type":"tags"},{"name":"color","type":"string","max_len":24},{"name":"email","type":"string","max_len":320},{"name":"phone","type":"string","max_len":64},{"name":"cv_url","type":"url","max_len":2048},{"name":"github","type":"url","max_len":2048},{"name":"skills","type":"tags"},{"name":"country","type":"string","max_len":120},{"name":"summary","type":"string","max_len":4000},{"name":"currency","type":"string","max_len":8},{"name":"linkedin","type":"url","max_len":2048},{"name":"pronouns","type":"string","max_len":32},{"name":"languages","type":"tags"},{"name":"last_name","type":"string","max_len":120},{"name":"portfolio","type":"url","max_len":2048},{"name":"source_id","type":"string","max_len":64,"ref":{"type":"source","owned":false,"optional":false}},{"name":"cv_blob_id","type":"string","max_len":64},{"name":"first_name","type":"string","max_len":120},{"name":"salutation","type":"enum","values":["herr","frau","divers","neutral"]},{"name":"pool_status","type":"enum","values":["active","talent_pool","blocked","withdrawn"]},{"name":"current_role","type":"string","max_len":200},{"name":"gdpr_consent","type":"bool"},{"name":"source_label","type":"string","max_len":200},{"name":"available_from","type":"string","max_len":32},{"name":"avatar_blob_id","type":"string","max_len":64},{"name":"current_company","type":"string","max_len":200},{"name":"gdpr_consent_at","type":"string","max_len":32},{"name":"last_touched_at","type":"string","max_len":32},{"name":"preferred_locale","type":"string","max_len":16},{"name":"years_experience","type":"number"},{"name":"salary_expectation","type":"number"},{"name":"gdpr_retention_until","type":"string","max_len":32}]},"email_template":{"ops":["list","read","create","update","delete"],"create_fields":["name","category","subject","body","language","stage_trigger","auto_send","active","variables_doc"],"update_fields":["name","category","subject","body","language","stage_trigger","auto_send","active","variables_doc"],"allowed_filters":["data__name","data__category","data__stage_trigger","data__active","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","data__name"],"default_sort":"data__name","max_limit":100,"fields":[{"name":"body","type":"string","max_len":16000},{"name":"name","type":"string","max_len":200},{"name":"active","type":"bool"},{"name":"subject","type":"string","max_len":400},{"name":"category","type":"enum","values":["acknowledge","screening_invite","interview_invite","rejection","offer","talent_pool","other"]},{"name":"language","type":"string","max_len":16},{"name":"auto_send","type":"bool"},{"name":"stage_trigger","type":"enum","values":["","new","review","screening","interview","offer","hired","rejected","talent_pool"]},{"name":"variables_doc","type":"string","max_len":2000}]},"evaluation":{"ops":["list","read","create","update","delete"],"create_fields":["application_id","interview_id","interviewer_id","skills_score","culture_score","communication_score","potential_score","overall_score","recommendation","highlights","concerns","summary"],"update_fields":["skills_score","culture_score","communication_score","potential_score","overall_score","recommendation","highlights","concerns","summary"],"allowed_filters":["data__application_id","data__interview_id","data__interviewer_id","data__recommendation","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","data__overall_score"],"default_sort":"created_at","max_limit":200,"fields":[{"name":"summary","type":"string","max_len":4000},{"name":"concerns","type":"string","max_len":4000},{"name":"highlights","type":"string","max_len":4000},{"name":"interview_id","type":"string","max_len":64,"ref":{"type":"interview","owned":false,"optional":false}},{"name":"skills_score","type":"number"},{"name":"culture_score","type":"number"},{"name":"overall_score","type":"number"},{"name":"application_id","type":"string","max_len":64,"ref":{"type":"application","owned":false,"optional":false}},{"name":"interviewer_id","type":"string","max_len":64},{"name":"recommendation","type":"enum","values":["strong_yes","yes","neutral","no","strong_no"]},{"name":"potential_score","type":"number"},{"name":"communication_score","type":"number"}]},"interview":{"ops":["list","read","create","update","delete"],"create_fields":["application_id","candidate_id","job_id","kind","status","title","scheduled_at","duration_minutes","location","meeting_url","interviewer_id","interviewer_ids","agenda","notes","send_invite"],"update_fields":["kind","status","title","scheduled_at","duration_minutes","location","meeting_url","interviewer_id","interviewer_ids","agenda","notes","send_invite"],"allowed_filters":["data__application_id","data__candidate_id","data__job_id","data__kind","data__status","data__interviewer_id","status","is_archived","owned_by","created_by"],"allowed_sorts":["data__scheduled_at","created_at","updated_at"],"default_sort":"data__scheduled_at","max_limit":200,"fields":[{"name":"kind","type":"enum","values":["phone","video","onsite","take_home","panel","trial_day"]},{"name":"notes","type":"string","max_len":8000},{"name":"title","type":"string","max_len":200},{"name":"agenda","type":"string","max_len":4000},{"name":"job_id","type":"string","max_len":64},{"name":"status","type":"enum","values":["scheduled","completed","no_show","cancelled","rescheduled"]},{"name":"location","type":"string","max_len":200},{"name":"meeting_url","type":"url","max_len":2048},{"name":"send_invite","type":"bool"},{"name":"candidate_id","type":"string","max_len":64},{"name":"scheduled_at","type":"string","max_len":32},{"name":"application_id","type":"string","max_len":64,"ref":{"type":"application","owned":false,"optional":false}},{"name":"interviewer_id","type":"string","max_len":64},{"name":"interviewer_ids","type":"list"},{"name":"duration_minutes","type":"number"}]},"job":{"ops":["list","read","create","update","delete"],"create_fields":["title","slug","department","location","country","remote","employment_type","seniority","headcount","salary_min","salary_max","currency","salary_visibility","summary","description","responsibilities","requirements","nice_to_have","benefits","language","status","public","ai_screen_enabled","ai_screen_prompt","knockout_questions","screening_questions","tags","hiring_manager_id","team_ids","opened_at","target_close_date","closed_at","external_apply_url","color"],"update_fields":["title","slug","department","location","country","remote","employment_type","seniority","headcount","salary_min","salary_max","currency","salary_visibility","summary","description","responsibilities","requirements","nice_to_have","benefits","language","status","public","ai_screen_enabled","ai_screen_prompt","knockout_questions","screening_questions","tags","hiring_manager_id","team_ids","opened_at","target_close_date","closed_at","external_apply_url","color"],"allowed_filters":["data__title","data__department","data__location","data__employment_type","data__seniority","data__remote","data__status","data__public","data__hiring_manager_id","data__tags","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","updated_at","data__title","data__opened_at","data__target_close_date"],"default_sort":"created_at","max_limit":200,"fields":[{"name":"slug","type":"string","max_len":120},{"name":"tags","type":"tags"},{"name":"color","type":"string","max_len":24},{"name":"title","type":"string","max_len":200},{"name":"public","type":"bool"},{"name":"remote","type":"enum","values":["onsite","hybrid","remote"]},{"name":"status","type":"enum","values":["draft","open","paused","closed","filled"]},{"name":"country","type":"string","max_len":80},{"name":"summary","type":"string","max_len":600},{"name":"benefits","type":"string","max_len":4000},{"name":"currency","type":"string","max_len":8},{"name":"language","type":"string","max_len":16},{"name":"location","type":"string","max_len":120},{"name":"team_ids","type":"list"},{"name":"closed_at","type":"string","max_len":32},{"name":"headcount","type":"number"},{"name":"opened_at","type":"string","max_len":32},{"name":"seniority","type":"enum","values":["junior","mid","senior","lead","principal"]},{"name":"department","type":"string","max_len":120},{"name":"salary_max","type":"number"},{"name":"salary_min","type":"number"},{"name":"description","type":"string","max_len":16000},{"name":"nice_to_have","type":"string","max_len":4000},{"name":"requirements","type":"string","max_len":8000},{"name":"employment_type","type":"enum","values":["full_time","part_time","internship","working_student","freelance","contract"]},{"name":"ai_screen_prompt","type":"string","max_len":4000},{"name":"responsibilities","type":"string","max_len":8000},{"name":"ai_screen_enabled","type":"bool"},{"name":"hiring_manager_id","type":"string","max_len":64},{"name":"salary_visibility","type":"enum","values":["public","team","private"]},{"name":"target_close_date","type":"string","max_len":32},{"name":"external_apply_url","type":"url","max_len":2048},{"name":"knockout_questions","type":"list"},{"name":"screening_questions","type":"list"}]},"message":{"ops":["list","read","create","update","delete"],"create_fields":["candidate_id","application_id","channel","direction","subject","body","status","sent_at","delivered_at","read_at","template_id","from_address","to_address","cc_addresses","thread_id","error"],"update_fields":["subject","body","status","sent_at","delivered_at","read_at","thread_id","error"],"allowed_filters":["data__candidate_id","data__application_id","data__channel","data__status","data__template_id","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","data__sent_at"],"default_sort":"-data__sent_at","max_limit":200,"fields":[{"name":"body","type":"string","max_len":16000},{"name":"error","type":"string","max_len":600},{"name":"status","type":"enum","values":["draft","queued","sent","delivered","failed","bounced"]},{"name":"channel","type":"enum","values":["email","note"]},{"name":"read_at","type":"string","max_len":32},{"name":"sent_at","type":"string","max_len":32},{"name":"subject","type":"string","max_len":400},{"name":"direction","type":"enum","values":["outbound","inbound"]},{"name":"thread_id","type":"string","max_len":200},{"name":"to_address","type":"string","max_len":320},{"name":"template_id","type":"string","max_len":64,"ref":{"type":"email_template","owned":false,"optional":false}},{"name":"candidate_id","type":"string","max_len":64},{"name":"cc_addresses","type":"list"},{"name":"delivered_at","type":"string","max_len":32},{"name":"from_address","type":"string","max_len":320},{"name":"application_id","type":"string","max_len":64}]},"offer":{"ops":["list","read","create","update","delete"],"create_fields":["application_id","candidate_id","job_id","salary_gross","salary_period","currency","bonus","bonus_note","vacation_days","start_date","expires_at","term","term_until","weekly_hours","remote_policy","status","sent_at","decided_at","letter_body","letter_blob_id","decline_reason"],"update_fields":["salary_gross","salary_period","currency","bonus","bonus_note","vacation_days","start_date","expires_at","term","term_until","weekly_hours","remote_policy","status","sent_at","decided_at","letter_body","letter_blob_id","decline_reason"],"allowed_filters":["data__application_id","data__candidate_id","data__job_id","data__status","data__currency","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","data__start_date","data__sent_at"],"default_sort":"created_at","max_limit":100,"fields":[{"name":"term","type":"enum","values":["permanent","fixed_term","trial","intern","freelance"]},{"name":"bonus","type":"number"},{"name":"job_id","type":"string","max_len":64},{"name":"status","type":"enum","values":["draft","sent","accepted","declined","withdrawn","expired"]},{"name":"sent_at","type":"string","max_len":32},{"name":"currency","type":"string","max_len":8},{"name":"bonus_note","type":"string","max_len":600},{"name":"decided_at","type":"string","max_len":32},{"name":"expires_at","type":"string","max_len":32},{"name":"start_date","type":"string","max_len":32},{"name":"term_until","type":"string","max_len":32},{"name":"letter_body","type":"string","max_len":16000},{"name":"candidate_id","type":"string","max_len":64},{"name":"salary_gross","type":"number"},{"name":"weekly_hours","type":"number"},{"name":"remote_policy","type":"string","max_len":200},{"name":"salary_period","type":"enum","values":["yearly","monthly","daily","hourly"]},{"name":"vacation_days","type":"number"},{"name":"application_id","type":"string","max_len":64,"ref":{"type":"application","owned":false,"optional":false}},{"name":"decline_reason","type":"string","max_len":600},{"name":"letter_blob_id","type":"string","max_len":64}]},"source":{"ops":["list","read","create","update","delete"],"create_fields":["name","kind","url","active","notes"],"update_fields":["name","kind","url","active","notes"],"allowed_filters":["data__name","data__kind","data__active","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","data__name"],"default_sort":"data__name","max_limit":100,"fields":[{"name":"url","type":"url","max_len":2048},{"name":"kind","type":"enum","values":["linkedin","indeed","stepstone","xing","honeypot","kununu","careers_page","referral","active_sourcing","agency","event","other"]},{"name":"name","type":"string","max_len":200},{"name":"notes","type":"string","max_len":2000},{"name":"active","type":"bool"}]},"task":{"ops":["list","read","create","update","delete"],"create_fields":["title","description","due_date","completed","completed_at","priority","assigned_to","parent_kind","parent_id"],"update_fields":["title","description","due_date","completed","completed_at","priority","assigned_to"],"allowed_filters":["data__parent_id","data__parent_kind","data__assigned_to","data__completed","data__priority","status","is_archived","owned_by","created_by"],"allowed_sorts":["data__due_date","created_at","data__priority"],"default_sort":"data__due_date","max_limit":200,"fields":[{"name":"title","type":"string","max_len":200},{"name":"due_date","type":"string","max_len":32},{"name":"priority","type":"enum","values":["low","normal","high","urgent"]},{"name":"completed","type":"bool"},{"name":"parent_id","type":"string","max_len":64},{"name":"assigned_to","type":"string","max_len":64},{"name":"description","type":"string","max_len":4000},{"name":"parent_kind","type":"enum","values":["candidate","application","job"]},{"name":"completed_at","type":"string","max_len":32}]}}"####; // ── 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 `application` rows. pub fn application_list(&self, opts: ListOpts) -> Result { self.request_list("/xapi2/data/application", opts) } /// Fetch one `application` row by id. pub fn application_get(&self, id: &str) -> Result { self.request_json("GET", &format!("/xapi2/data/application/{}", id), None) } /// Create a new `application` row. pub fn application_create(&self, data: Value) -> Result { self.request_json("POST", "/xapi2/data/application", Some(data)) } /// Patch an existing `application` row. pub fn application_update(&self, id: &str, data: Value) -> Result { self.request_json("PATCH", &format!("/xapi2/data/application/{}", id), Some(data)) } /// Delete a `application` row. pub fn application_delete(&self, id: &str) -> Result<(), ApiError> { self.request_json("DELETE", &format!("/xapi2/data/application/{}", id), None)?; Ok(()) } /// List `application_note` rows. pub fn application_note_list(&self, opts: ListOpts) -> Result { self.request_list("/xapi2/data/application_note", opts) } /// Fetch one `application_note` row by id. pub fn application_note_get(&self, id: &str) -> Result { self.request_json("GET", &format!("/xapi2/data/application_note/{}", id), None) } /// Create a new `application_note` row. pub fn application_note_create(&self, data: Value) -> Result { self.request_json("POST", "/xapi2/data/application_note", Some(data)) } /// Patch an existing `application_note` row. pub fn application_note_update(&self, id: &str, data: Value) -> Result { self.request_json("PATCH", &format!("/xapi2/data/application_note/{}", id), Some(data)) } /// Delete a `application_note` row. pub fn application_note_delete(&self, id: &str) -> Result<(), ApiError> { self.request_json("DELETE", &format!("/xapi2/data/application_note/{}", id), None)?; Ok(()) } /// List `candidate` rows. pub fn candidate_list(&self, opts: ListOpts) -> Result { self.request_list("/xapi2/data/candidate", opts) } /// Fetch one `candidate` row by id. pub fn candidate_get(&self, id: &str) -> Result { self.request_json("GET", &format!("/xapi2/data/candidate/{}", id), None) } /// Create a new `candidate` row. pub fn candidate_create(&self, data: Value) -> Result { self.request_json("POST", "/xapi2/data/candidate", Some(data)) } /// Patch an existing `candidate` row. pub fn candidate_update(&self, id: &str, data: Value) -> Result { self.request_json("PATCH", &format!("/xapi2/data/candidate/{}", id), Some(data)) } /// Delete a `candidate` row. pub fn candidate_delete(&self, id: &str) -> Result<(), ApiError> { self.request_json("DELETE", &format!("/xapi2/data/candidate/{}", id), None)?; Ok(()) } /// List `email_template` rows. pub fn email_template_list(&self, opts: ListOpts) -> Result { self.request_list("/xapi2/data/email_template", opts) } /// Fetch one `email_template` row by id. pub fn email_template_get(&self, id: &str) -> Result { self.request_json("GET", &format!("/xapi2/data/email_template/{}", id), None) } /// Create a new `email_template` row. pub fn email_template_create(&self, data: Value) -> Result { self.request_json("POST", "/xapi2/data/email_template", Some(data)) } /// Patch an existing `email_template` row. pub fn email_template_update(&self, id: &str, data: Value) -> Result { self.request_json("PATCH", &format!("/xapi2/data/email_template/{}", id), Some(data)) } /// Delete a `email_template` row. pub fn email_template_delete(&self, id: &str) -> Result<(), ApiError> { self.request_json("DELETE", &format!("/xapi2/data/email_template/{}", id), None)?; Ok(()) } /// List `evaluation` rows. pub fn evaluation_list(&self, opts: ListOpts) -> Result { self.request_list("/xapi2/data/evaluation", opts) } /// Fetch one `evaluation` row by id. pub fn evaluation_get(&self, id: &str) -> Result { self.request_json("GET", &format!("/xapi2/data/evaluation/{}", id), None) } /// Create a new `evaluation` row. pub fn evaluation_create(&self, data: Value) -> Result { self.request_json("POST", "/xapi2/data/evaluation", Some(data)) } /// Patch an existing `evaluation` row. pub fn evaluation_update(&self, id: &str, data: Value) -> Result { self.request_json("PATCH", &format!("/xapi2/data/evaluation/{}", id), Some(data)) } /// Delete a `evaluation` row. pub fn evaluation_delete(&self, id: &str) -> Result<(), ApiError> { self.request_json("DELETE", &format!("/xapi2/data/evaluation/{}", id), None)?; Ok(()) } /// List `interview` rows. pub fn interview_list(&self, opts: ListOpts) -> Result { self.request_list("/xapi2/data/interview", opts) } /// Fetch one `interview` row by id. pub fn interview_get(&self, id: &str) -> Result { self.request_json("GET", &format!("/xapi2/data/interview/{}", id), None) } /// Create a new `interview` row. pub fn interview_create(&self, data: Value) -> Result { self.request_json("POST", "/xapi2/data/interview", Some(data)) } /// Patch an existing `interview` row. pub fn interview_update(&self, id: &str, data: Value) -> Result { self.request_json("PATCH", &format!("/xapi2/data/interview/{}", id), Some(data)) } /// Delete a `interview` row. pub fn interview_delete(&self, id: &str) -> Result<(), ApiError> { self.request_json("DELETE", &format!("/xapi2/data/interview/{}", id), None)?; Ok(()) } /// List `job` rows. pub fn job_list(&self, opts: ListOpts) -> Result { self.request_list("/xapi2/data/job", opts) } /// Fetch one `job` row by id. pub fn job_get(&self, id: &str) -> Result { self.request_json("GET", &format!("/xapi2/data/job/{}", id), None) } /// Create a new `job` row. pub fn job_create(&self, data: Value) -> Result { self.request_json("POST", "/xapi2/data/job", Some(data)) } /// Patch an existing `job` row. pub fn job_update(&self, id: &str, data: Value) -> Result { self.request_json("PATCH", &format!("/xapi2/data/job/{}", id), Some(data)) } /// Delete a `job` row. pub fn job_delete(&self, id: &str) -> Result<(), ApiError> { self.request_json("DELETE", &format!("/xapi2/data/job/{}", id), None)?; Ok(()) } /// List `message` rows. pub fn message_list(&self, opts: ListOpts) -> Result { self.request_list("/xapi2/data/message", opts) } /// Fetch one `message` row by id. pub fn message_get(&self, id: &str) -> Result { self.request_json("GET", &format!("/xapi2/data/message/{}", id), None) } /// Create a new `message` row. pub fn message_create(&self, data: Value) -> Result { self.request_json("POST", "/xapi2/data/message", Some(data)) } /// Patch an existing `message` row. pub fn message_update(&self, id: &str, data: Value) -> Result { self.request_json("PATCH", &format!("/xapi2/data/message/{}", id), Some(data)) } /// Delete a `message` row. pub fn message_delete(&self, id: &str) -> Result<(), ApiError> { self.request_json("DELETE", &format!("/xapi2/data/message/{}", id), None)?; Ok(()) } /// List `offer` rows. pub fn offer_list(&self, opts: ListOpts) -> Result { self.request_list("/xapi2/data/offer", opts) } /// Fetch one `offer` row by id. pub fn offer_get(&self, id: &str) -> Result { self.request_json("GET", &format!("/xapi2/data/offer/{}", id), None) } /// Create a new `offer` row. pub fn offer_create(&self, data: Value) -> Result { self.request_json("POST", "/xapi2/data/offer", Some(data)) } /// Patch an existing `offer` row. pub fn offer_update(&self, id: &str, data: Value) -> Result { self.request_json("PATCH", &format!("/xapi2/data/offer/{}", id), Some(data)) } /// Delete a `offer` row. pub fn offer_delete(&self, id: &str) -> Result<(), ApiError> { self.request_json("DELETE", &format!("/xapi2/data/offer/{}", id), None)?; Ok(()) } /// List `source` rows. pub fn source_list(&self, opts: ListOpts) -> Result { self.request_list("/xapi2/data/source", opts) } /// Fetch one `source` row by id. pub fn source_get(&self, id: &str) -> Result { self.request_json("GET", &format!("/xapi2/data/source/{}", id), None) } /// Create a new `source` row. pub fn source_create(&self, data: Value) -> Result { self.request_json("POST", "/xapi2/data/source", Some(data)) } /// Patch an existing `source` row. pub fn source_update(&self, id: &str, data: Value) -> Result { self.request_json("PATCH", &format!("/xapi2/data/source/{}", id), Some(data)) } /// Delete a `source` row. pub fn source_delete(&self, id: &str) -> Result<(), ApiError> { self.request_json("DELETE", &format!("/xapi2/data/source/{}", id), None)?; Ok(()) } /// List `task` rows. pub fn task_list(&self, opts: ListOpts) -> Result { self.request_list("/xapi2/data/task", opts) } /// Fetch one `task` row by id. pub fn task_get(&self, id: &str) -> Result { self.request_json("GET", &format!("/xapi2/data/task/{}", id), None) } /// Create a new `task` row. pub fn task_create(&self, data: Value) -> Result { self.request_json("POST", "/xapi2/data/task", Some(data)) } /// Patch an existing `task` row. pub fn task_update(&self, id: &str, data: Value) -> Result { self.request_json("PATCH", &format!("/xapi2/data/task/{}", id), Some(data)) } /// Delete a `task` row. pub fn task_delete(&self, id: &str) -> Result<(), ApiError> { self.request_json("DELETE", &format!("/xapi2/data/task/{}", id), None)?; Ok(()) } }