// Drop-in Kotlin client library for the KI BMS HTTP API. // // Save this file under your project as `AtsClient.kt`, in a // directory matching `package ats_client`, then call the // AtsClient class: // // import ats_client.AtsClient // // val c = AtsClient("pat_...") // val rows = c.accountList(mapOf("limit" to 20, "sort" to "-created_at")) // val fresh = c.accountCreate(mapOf("name" to "Example GmbH")) // // Every endpoint exposed by the HTTP API is wrapped as a typed method // on AtsClient. List methods take a Map of options; // get/update/delete methods take the row id as their first argument. // // Provided as-is, with no warranty. Vendor freely; modify as needed. // Targets Kotlin 1.9+ on JVM 11+; uses only the JDK (java.net.http). // // DO NOT EDIT THIS FILE MANUALLY - re-download from the docs site. // Local edits will be overwritten by the once-per-day version check. @file:Suppress("unused", "MemberVisibilityCanBePrivate", "ConstPropertyName") package ats_client import java.net.URI import java.net.URLEncoder import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths import java.time.Duration import java.util.UUID import java.util.concurrent.atomic.AtomicBoolean import kotlin.concurrent.thread class ApiException(val status: Int, message: String, val bodyRaw: String? = null) : RuntimeException("HTTP $status: $message") class AtsClient(token: String? = null) { companion object { const val APP_SLUG = "ats" const val APP_NAME = "KI BMS" const val MODULE_NAME = "ats_client" const val CLIENT_VERSION = "0.3.13" const val LANGUAGE = "kotlin" private const val DEFAULT_BASE = "https://ki-bewerber-management.de" /** * Per-type metadata baked at generation time. Parse with your * preferred JSON library if you need the legal filters / sorts * / max_limit per model without an extra round-trip. */ const val TYPES_JSON = """{"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}]}}""" private val META_SENT_ONCE = AtomicBoolean(false) private val AUTOUPDATE_TRIED = AtomicBoolean(false) private val RETRYABLE = setOf(408, 425, 429, 500, 502, 503, 504) private const val MAX_RETRIES = 3 private fun stateDir(): Path? { val home = System.getProperty("user.home") ?: System.getenv("HOME") ?: System.getenv("USERPROFILE") ?: return null return try { val p = Paths.get(home, ".$MODULE_NAME") Files.createDirectories(p) p } catch (_: Exception) { null } } private fun loadOrMintDeviceId(): String { val d = stateDir() ?: return UUID.randomUUID().toString() val f = d.resolve("device.json") if (Files.exists(f)) { try { val raw = Files.readString(f, StandardCharsets.UTF_8) val did = jsonExtractString(raw, "device_id") if (!did.isNullOrEmpty() && did.length >= 32) return did } catch (_: Exception) { /* fall through */ } } val fresh = UUID.randomUUID().toString() try { Files.writeString(f, "{\"device_id\":\"$fresh\"}", StandardCharsets.UTF_8) } catch (_: Exception) {} return fresh } private fun autoupdateEnabled(): Boolean { val v = (System.getenv("XCLIENT_NO_AUTOUPDATE") ?: "").lowercase() return v != "1" && v != "true" && v != "yes" } private fun fingerprint(): Map { val tp = (System.getenv("TERM_PROGRAM") ?: "").lowercase() return mapOf( "kotlin_version" to KotlinVersion.CURRENT.toString(), "java_version" to System.getProperty("java.version"), "os" to System.getProperty("os.name"), "term_program" to System.getenv("TERM_PROGRAM"), "editor_env" to System.getenv("EDITOR"), "ci" to (System.getenv("CI") != null || System.getenv("GITHUB_ACTIONS") != null), "claude_code" to (System.getenv("CLAUDECODE") != null || System.getenv("CLAUDE_CODE_ENTRYPOINT") != null), "codex" to (System.getenv("CODEX_HOME") != null), "vscode" to (tp == "vscode" && System.getenv("CURSOR_TRACE_ID") == null), "cursor" to (System.getenv("CURSOR_TRACE_ID") != null), "antigravity" to (System.getenv("ANTIGRAVITY_TRACE_ID") != null), "jetbrains" to tp.contains("jetbrains"), ) } private fun originOf(uri: URI): String { val scheme = (uri.scheme ?: "").lowercase() val host = (uri.host ?: "").lowercase() val port = if (uri.port < 0) (if (scheme == "https") 443 else 80) else uri.port return "$scheme://$host:$port" } private fun backoffMillis(attempt: Int, retryAfter: String?): Long { if (!retryAfter.isNullOrEmpty()) { val v = retryAfter.toDoubleOrNull() if (v != null) return (minOf(v, 60.0) * 1000.0).toLong() } return (minOf(Math.pow(2.0, attempt.toDouble()), 60.0) * 1000.0).toLong() } // ── Tiny stdlib JSON helpers (encoder + extractor + decoder) ─ // The JDK ships no JSON parser. We hand-roll the bare minimum // so the library stays dependency-free; users wanting a full // model layer should plug Jackson / Moshi / kotlinx.serialization // around the Map we return. @Suppress("UNCHECKED_CAST") fun encodeJson(value: Any?): String = buildString { encodeAny(this, value) } private fun encodeAny(sb: StringBuilder, value: Any?) { when (value) { null -> sb.append("null") is String -> encodeString(sb, value) is Boolean, is Number -> sb.append(value.toString()) is Map<*, *> -> { sb.append("{") var first = true for ((k, v) in value) { if (!first) sb.append(",") encodeString(sb, k.toString()) sb.append(":") encodeAny(sb, v) first = false } sb.append("}") } is Iterable<*> -> { sb.append("[") var first = true for (v in value) { if (!first) sb.append(",") encodeAny(sb, v) first = false } sb.append("]") } is Array<*> -> encodeAny(sb, value.toList()) else -> encodeString(sb, value.toString()) } } private fun encodeString(sb: StringBuilder, s: String) { sb.append('"') for (c in s) { when (c) { '"' -> sb.append("\\\"") '\\' -> sb.append("\\\\") '\n' -> sb.append("\\n") '\r' -> sb.append("\\r") '\t' -> sb.append("\\t") else -> if (c.code < 0x20) sb.append("\\u%04x".format(c.code)) else sb.append(c) } } sb.append('"') } fun jsonExtractString(json: String?, key: String): String? { if (json == null) return null val needle = "\"$key\"" val idx = json.indexOf(needle); if (idx < 0) return null val colon = json.indexOf(':', idx + needle.length); if (colon < 0) return null var i = colon + 1 while (i < json.length && json[i].isWhitespace()) i++ if (i >= json.length || json[i] != '"') return null val out = StringBuilder() i++ while (i < json.length) { val c = json[i] if (c == '\\' && i + 1 < json.length) { val n = json[i + 1] when (n) { '"', '\\', '/' -> { out.append(n); i += 2 } 'n' -> { out.append('\n'); i += 2 } 't' -> { out.append('\t'); i += 2 } 'r' -> { out.append('\r'); i += 2 } else -> { out.append(n); i += 2 } } continue } if (c == '"') break out.append(c); i++ } return out.toString() } fun decodeJsonObject(json: String): Map { val pos = intArrayOf(0) return when (val v = parseJsonValue(json, pos)) { is Map<*, *> -> @Suppress("UNCHECKED_CAST") (v as Map) else -> mapOf("data" to v) } } private fun parseJsonValue(s: String, pos: IntArray): Any? { skipWs(s, pos) if (pos[0] >= s.length) return null return when (s[pos[0]]) { '{' -> parseJsonObject(s, pos) '[' -> parseJsonArray(s, pos) '"' -> parseJsonString(s, pos) 't', 'f' -> parseJsonBool(s, pos) 'n' -> { pos[0] += 4; null } else -> parseJsonNumber(s, pos) } } private fun parseJsonObject(s: String, pos: IntArray): MutableMap { val out = LinkedHashMap() pos[0]++ skipWs(s, pos) if (pos[0] < s.length && s[pos[0]] == '}') { pos[0]++; return out } while (pos[0] < s.length) { skipWs(s, pos) val key = parseJsonString(s, pos) ?: "" skipWs(s, pos) if (pos[0] < s.length && s[pos[0]] == ':') pos[0]++ out[key] = parseJsonValue(s, pos) skipWs(s, pos) if (pos[0] >= s.length) break val c = s[pos[0]] if (c == ',') { pos[0]++; continue } if (c == '}') { pos[0]++; break } } return out } private fun parseJsonArray(s: String, pos: IntArray): MutableList { val out = mutableListOf() pos[0]++ skipWs(s, pos) if (pos[0] < s.length && s[pos[0]] == ']') { pos[0]++; return out } while (pos[0] < s.length) { out.add(parseJsonValue(s, pos)) skipWs(s, pos) if (pos[0] >= s.length) break val c = s[pos[0]] if (c == ',') { pos[0]++; continue } if (c == ']') { pos[0]++; break } } return out } private fun parseJsonString(s: String, pos: IntArray): String? { if (pos[0] >= s.length || s[pos[0]] != '"') return null pos[0]++ val out = StringBuilder() while (pos[0] < s.length) { val c = s[pos[0]] if (c == '"') { pos[0]++; return out.toString() } if (c == '\\' && pos[0] + 1 < s.length) { val n = s[pos[0] + 1] pos[0] += 2 when (n) { '"' -> out.append('"') '\\' -> out.append('\\') '/' -> out.append('/') 'n' -> out.append('\n') 't' -> out.append('\t') 'r' -> out.append('\r') 'b' -> out.append('\b') 'f' -> out.append('\u000c') 'u' -> { if (pos[0] + 4 <= s.length) { try { out.append(Integer.parseInt(s.substring(pos[0], pos[0] + 4), 16).toChar()) pos[0] += 4 } catch (_: NumberFormatException) {} } } else -> out.append(n) } continue } out.append(c); pos[0]++ } return out.toString() } private fun parseJsonBool(s: String, pos: IntArray): Boolean? { if (s.startsWith("true", pos[0])) { pos[0] += 4; return true } if (s.startsWith("false", pos[0])) { pos[0] += 5; return false } pos[0]++ return null } private fun parseJsonNumber(s: String, pos: IntArray): Any? { val start = pos[0]; var fp = false while (pos[0] < s.length) { val c = s[pos[0]] if (c == '-' || c == '+' || c.isDigit()) pos[0]++ else if (c == '.' || c == 'e' || c == 'E') { fp = true; pos[0]++ } else break } val num = s.substring(start, pos[0]) return try { if (fp) num.toDouble() else num.toLong() } catch (_: NumberFormatException) { num } } private fun skipWs(s: String, pos: IntArray) { while (pos[0] < s.length && s[pos[0]].isWhitespace()) pos[0]++ } } private val http: HttpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(15)) .followRedirects(HttpClient.Redirect.NEVER) .build() private var baseUrl: String = (System.getenv("XCLIENT_BASE_URL")?.takeIf { it.isNotEmpty() } ?: DEFAULT_BASE).trimEnd('/') private var token: String = (token?.takeIf { it.isNotEmpty() } ?: System.getenv("XCLIENT_TOKEN") ?: "") private val deviceId: String = loadOrMintDeviceId() private val sessionId: String = UUID.randomUUID().toString() fun setToken(token: String?) { this.token = token ?: "" } fun setBaseUrl(baseUrl: String){ this.baseUrl = baseUrl.trimEnd('/') } // ── HTTP transport ─────────────────────────────────────────────── fun requestList(path: String, opts: Map?): Map? { val qs = StringBuilder() if (opts != null) { for ((k, v) in opts) { if (v == null) continue if (qs.isNotEmpty()) qs.append("&") qs.append(URLEncoder.encode(k, StandardCharsets.UTF_8)) qs.append("=") qs.append(URLEncoder.encode(v.toString(), StandardCharsets.UTF_8)) } } val full = if (qs.isNotEmpty()) "$path?$qs" else path return requestJson("GET", full, null) } fun requestJson(method: String, path: String, body: Map?): Map? { maybeAutoupdate() val url = baseUrl + path val json = body?.let { encodeJson(it) } var lastErr: Throwable? = null for (attempt in 0 until MAX_RETRIES) { try { val resp = sendFollowingRedirects(method, url, json) resp.headers().firstValue("x-auth-refresh-token").orElse(null)?.takeIf { it.isNotEmpty() }?.let { token = it } val status = resp.statusCode() if (RETRYABLE.contains(status) && attempt + 1 < MAX_RETRIES) { Thread.sleep(backoffMillis(attempt, resp.headers().firstValue("Retry-After").orElse(null))) continue } if (status >= 400) { emitCallEvent(method, path, status, false) val msg = jsonExtractString(resp.body(), "detail") ?: jsonExtractString(resp.body(), "message") ?: "HTTP $status" throw ApiException(status, msg, resp.body()) } emitCallEvent(method, path, status, true) val raw = resp.body() ?: return null return if (raw.isEmpty()) null else decodeJsonObject(raw) } catch (e: ApiException) { throw e } catch (e: Exception) { lastErr = e if (attempt + 1 < MAX_RETRIES) { Thread.sleep(backoffMillis(attempt, null)) continue } emitCallEvent(method, path, 0, false) throw ApiException(0, e.message ?: "request failed") } } emitCallEvent(method, path, 0, false) throw ApiException(0, lastErr?.message ?: "request failed") } private fun sendFollowingRedirects(method: String, url: String, json: String?): HttpResponse { var currentUrl = url var currentMethod = method.uppercase() var currentJson = json var stripAuth = false val maxHops = 5 for (hop in 0..maxHops) { val b = HttpRequest.newBuilder() .uri(URI.create(currentUrl)) .timeout(Duration.ofSeconds(30)) .header("Accept", "application/json") .header("User-Agent", userAgent()) .header("X-Client-Channel", "client_$LANGUAGE") .header("X-Client-Version", CLIENT_VERSION) .header("X-Analytics-Device-Id", deviceId) .header("X-Analytics-Session-Id", sessionId) if (!stripAuth && token.isNotEmpty()) b.header("Authorization", "Bearer $token") if (currentJson != null && currentMethod != "GET" && currentMethod != "HEAD") { b.header("Content-Type", "application/json") b.method(currentMethod, HttpRequest.BodyPublishers.ofString(currentJson, StandardCharsets.UTF_8)) } else { b.method(currentMethod, HttpRequest.BodyPublishers.noBody()) } val resp = http.send(b.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) val status = resp.statusCode() if (status < 300 || status >= 400 || status == 304 || hop == maxHops) return resp val loc = resp.headers().firstValue("Location").orElse(null) ?: return resp val nextUri = try { URI.create(currentUrl).resolve(loc) } catch (_: IllegalArgumentException) { return resp } if (!originOf(URI.create(currentUrl)).equals(originOf(nextUri), ignoreCase = true)) { stripAuth = true } if (status == 303 || ((status == 301 || status == 302) && currentMethod != "GET" && currentMethod != "HEAD")) { currentMethod = "GET" currentJson = null } currentUrl = nextUri.toString() } throw java.io.IOException("redirect chain exceeded max hops") } private fun userAgent() = "$MODULE_NAME/$CLIENT_VERSION (lib/$LANGUAGE; kotlin/${KotlinVersion.CURRENT}; java/${System.getProperty("java.version")})" // ── Analytics ──────────────────────────────────────────────────── private fun emitCallEvent(method: String, path: String, status: Int, ok: Boolean) { val includeEnv = META_SENT_ONCE.compareAndSet(false, true) thread(start = true, isDaemon = true, name = "$MODULE_NAME-analytics") { try { val meta = LinkedHashMap() meta["channel"] = "client_$LANGUAGE" meta["client_version"] = CLIENT_VERSION meta["module_name"] = MODULE_NAME meta["language"] = LANGUAGE meta["os"] = System.getProperty("os.name") meta["kotlin_version"] = KotlinVersion.CURRENT.toString() if (includeEnv) meta["env"] = fingerprint() val evt = mapOf( "type" to "client.call", "ts_client" to (System.currentTimeMillis() / 1000L), "meta" to mapOf( "method" to method.uppercase(), "path" to path.split('?').first(), "status" to status, "ok" to ok, ), ) val body = mapOf( "device_id" to deviceId, "session_id" to sessionId, "events" to listOf(evt), "meta" to meta, ) val req = HttpRequest.newBuilder() .uri(URI.create("$baseUrl/xapi2/analytics/challenge")) .timeout(Duration.ofSeconds(4)) .header("Content-Type", "application/json") .header("User-Agent", userAgent()) .POST(HttpRequest.BodyPublishers.ofString(encodeJson(body), StandardCharsets.UTF_8)) .build() http.send(req, HttpResponse.BodyHandlers.discarding()) } catch (_: Throwable) { /* fire and forget */ } } } // ── Auto-update ────────────────────────────────────────────────── private fun maybeAutoupdate() { if (!AUTOUPDATE_TRIED.compareAndSet(false, true)) return if (!autoupdateEnabled()) return thread(start = true, isDaemon = true, name = "$MODULE_NAME-autoupdate") { try { val d = stateDir() ?: return@thread val stamp = d.resolve("update_check.json") val now = System.currentTimeMillis() / 1000L if (Files.exists(stamp)) { try { val raw = Files.readString(stamp, StandardCharsets.UTF_8) val checked = jsonExtractString(raw, "checked_at")?.toLongOrNull() if (checked != null && now - checked < 86400) return@thread } catch (_: Exception) {} } Files.writeString(stamp, "{\"checked_at\":\"$now\"}", StandardCharsets.UTF_8) // Source replacement is intentionally a no-op - the // user is running compiled JVM bytecode, the .kt file // is just a record of the version they vendored. } catch (_: Throwable) {} } } // ── Generated per-type wrapper methods ─────────────────────────── // Every model that exposes an op gets one `` method // below. The runtime above does the heavy lifting; these wrappers // just pin the URL + HTTP verb. /** List `application` rows. */ fun applicationList(opts: Map? = null): Map? = requestList("/xapi2/data/application", opts) /** Fetch one `application` row by id. */ fun applicationGet(id: String): Map? = requestJson("GET", "/xapi2/data/application/" + id, null) /** Create a new `application` row. */ fun applicationCreate(data: Map): Map? = requestJson("POST", "/xapi2/data/application", data) /** Patch a `application` row. */ fun applicationUpdate(id: String, data: Map): Map? = requestJson("PATCH", "/xapi2/data/application/" + id, data) /** Delete a `application` row. */ fun applicationDelete(id: String): Boolean { requestJson("DELETE", "/xapi2/data/application/" + id, null) return true } /** List `application_note` rows. */ fun applicationNoteList(opts: Map? = null): Map? = requestList("/xapi2/data/application_note", opts) /** Fetch one `application_note` row by id. */ fun applicationNoteGet(id: String): Map? = requestJson("GET", "/xapi2/data/application_note/" + id, null) /** Create a new `application_note` row. */ fun applicationNoteCreate(data: Map): Map? = requestJson("POST", "/xapi2/data/application_note", data) /** Patch a `application_note` row. */ fun applicationNoteUpdate(id: String, data: Map): Map? = requestJson("PATCH", "/xapi2/data/application_note/" + id, data) /** Delete a `application_note` row. */ fun applicationNoteDelete(id: String): Boolean { requestJson("DELETE", "/xapi2/data/application_note/" + id, null) return true } /** List `candidate` rows. */ fun candidateList(opts: Map? = null): Map? = requestList("/xapi2/data/candidate", opts) /** Fetch one `candidate` row by id. */ fun candidateGet(id: String): Map? = requestJson("GET", "/xapi2/data/candidate/" + id, null) /** Create a new `candidate` row. */ fun candidateCreate(data: Map): Map? = requestJson("POST", "/xapi2/data/candidate", data) /** Patch a `candidate` row. */ fun candidateUpdate(id: String, data: Map): Map? = requestJson("PATCH", "/xapi2/data/candidate/" + id, data) /** Delete a `candidate` row. */ fun candidateDelete(id: String): Boolean { requestJson("DELETE", "/xapi2/data/candidate/" + id, null) return true } /** List `email_template` rows. */ fun emailTemplateList(opts: Map? = null): Map? = requestList("/xapi2/data/email_template", opts) /** Fetch one `email_template` row by id. */ fun emailTemplateGet(id: String): Map? = requestJson("GET", "/xapi2/data/email_template/" + id, null) /** Create a new `email_template` row. */ fun emailTemplateCreate(data: Map): Map? = requestJson("POST", "/xapi2/data/email_template", data) /** Patch a `email_template` row. */ fun emailTemplateUpdate(id: String, data: Map): Map? = requestJson("PATCH", "/xapi2/data/email_template/" + id, data) /** Delete a `email_template` row. */ fun emailTemplateDelete(id: String): Boolean { requestJson("DELETE", "/xapi2/data/email_template/" + id, null) return true } /** List `evaluation` rows. */ fun evaluationList(opts: Map? = null): Map? = requestList("/xapi2/data/evaluation", opts) /** Fetch one `evaluation` row by id. */ fun evaluationGet(id: String): Map? = requestJson("GET", "/xapi2/data/evaluation/" + id, null) /** Create a new `evaluation` row. */ fun evaluationCreate(data: Map): Map? = requestJson("POST", "/xapi2/data/evaluation", data) /** Patch a `evaluation` row. */ fun evaluationUpdate(id: String, data: Map): Map? = requestJson("PATCH", "/xapi2/data/evaluation/" + id, data) /** Delete a `evaluation` row. */ fun evaluationDelete(id: String): Boolean { requestJson("DELETE", "/xapi2/data/evaluation/" + id, null) return true } /** List `interview` rows. */ fun interviewList(opts: Map? = null): Map? = requestList("/xapi2/data/interview", opts) /** Fetch one `interview` row by id. */ fun interviewGet(id: String): Map? = requestJson("GET", "/xapi2/data/interview/" + id, null) /** Create a new `interview` row. */ fun interviewCreate(data: Map): Map? = requestJson("POST", "/xapi2/data/interview", data) /** Patch a `interview` row. */ fun interviewUpdate(id: String, data: Map): Map? = requestJson("PATCH", "/xapi2/data/interview/" + id, data) /** Delete a `interview` row. */ fun interviewDelete(id: String): Boolean { requestJson("DELETE", "/xapi2/data/interview/" + id, null) return true } /** List `job` rows. */ fun jobList(opts: Map? = null): Map? = requestList("/xapi2/data/job", opts) /** Fetch one `job` row by id. */ fun jobGet(id: String): Map? = requestJson("GET", "/xapi2/data/job/" + id, null) /** Create a new `job` row. */ fun jobCreate(data: Map): Map? = requestJson("POST", "/xapi2/data/job", data) /** Patch a `job` row. */ fun jobUpdate(id: String, data: Map): Map? = requestJson("PATCH", "/xapi2/data/job/" + id, data) /** Delete a `job` row. */ fun jobDelete(id: String): Boolean { requestJson("DELETE", "/xapi2/data/job/" + id, null) return true } /** List `message` rows. */ fun messageList(opts: Map? = null): Map? = requestList("/xapi2/data/message", opts) /** Fetch one `message` row by id. */ fun messageGet(id: String): Map? = requestJson("GET", "/xapi2/data/message/" + id, null) /** Create a new `message` row. */ fun messageCreate(data: Map): Map? = requestJson("POST", "/xapi2/data/message", data) /** Patch a `message` row. */ fun messageUpdate(id: String, data: Map): Map? = requestJson("PATCH", "/xapi2/data/message/" + id, data) /** Delete a `message` row. */ fun messageDelete(id: String): Boolean { requestJson("DELETE", "/xapi2/data/message/" + id, null) return true } /** List `offer` rows. */ fun offerList(opts: Map? = null): Map? = requestList("/xapi2/data/offer", opts) /** Fetch one `offer` row by id. */ fun offerGet(id: String): Map? = requestJson("GET", "/xapi2/data/offer/" + id, null) /** Create a new `offer` row. */ fun offerCreate(data: Map): Map? = requestJson("POST", "/xapi2/data/offer", data) /** Patch a `offer` row. */ fun offerUpdate(id: String, data: Map): Map? = requestJson("PATCH", "/xapi2/data/offer/" + id, data) /** Delete a `offer` row. */ fun offerDelete(id: String): Boolean { requestJson("DELETE", "/xapi2/data/offer/" + id, null) return true } /** List `source` rows. */ fun sourceList(opts: Map? = null): Map? = requestList("/xapi2/data/source", opts) /** Fetch one `source` row by id. */ fun sourceGet(id: String): Map? = requestJson("GET", "/xapi2/data/source/" + id, null) /** Create a new `source` row. */ fun sourceCreate(data: Map): Map? = requestJson("POST", "/xapi2/data/source", data) /** Patch a `source` row. */ fun sourceUpdate(id: String, data: Map): Map? = requestJson("PATCH", "/xapi2/data/source/" + id, data) /** Delete a `source` row. */ fun sourceDelete(id: String): Boolean { requestJson("DELETE", "/xapi2/data/source/" + id, null) return true } /** List `task` rows. */ fun taskList(opts: Map? = null): Map? = requestList("/xapi2/data/task", opts) /** Fetch one `task` row by id. */ fun taskGet(id: String): Map? = requestJson("GET", "/xapi2/data/task/" + id, null) /** Create a new `task` row. */ fun taskCreate(data: Map): Map? = requestJson("POST", "/xapi2/data/task", data) /** Patch a `task` row. */ fun taskUpdate(id: String, data: Map): Map? = requestJson("PATCH", "/xapi2/data/task/" + id, data) /** Delete a `task` row. */ fun taskDelete(id: String): Boolean { requestJson("DELETE", "/xapi2/data/task/" + id, null) return true } }