// Drop-in Swift client library for the KI BMS HTTP API. // // Save this file alongside your code as `AtsClient.swift` and use // the AtsClient class: // // let c = AtsClient(token: "pat_...") // let rows = try await c.accountList(opts: ListOpts(limit: 20, sort: "-created_at")) // let fresh = try await c.accountCreate(["name": "Example GmbH"]) // // Every endpoint exposed by the HTTP API is wrapped as an async method // on AtsClient. List methods take a ListOpts struct; get/update/delete // methods take the row id as their first argument. // // Provided as-is, with no warranty. Vendor freely; modify as needed. // Targets Swift 5.7+ on macOS 12 / iOS 15 / Linux (Foundation + // FoundationNetworking on Linux). No external packages required. // // DO NOT EDIT THIS FILE MANUALLY - re-download from the docs site. // Local edits will be overwritten by the once-per-day version check. import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif public enum AtsClientConstants { public static let appSlug = "ats" public static let appName = "KI BMS" public static let moduleName = "ats_client" public static let clientVersion = "0.3.13" public static let language = "swift" public static let defaultBase = "https://ki-bewerber-management.de" /// Per-type metadata baked at generation time. Parse with /// JSONSerialization if you need legal filters / sorts / max_limit /// per model without an extra round-trip. public static let typesJson = #"{"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}]}}"# } public struct ApiError: Error, CustomStringConvertible { public let status: Int public let message: String public let bodyRaw: Any? public init(status: Int, message: String, body: Any? = nil) { self.status = status self.message = message self.bodyRaw = body } public var description: String { "HTTP \(status): \(message)" } } public struct ListOpts { public var limit: Int? public var offset: Int? public var sort: String? public var q: String? public var filters: [String: Any]? public init(limit: Int? = nil, offset: Int? = nil, sort: String? = nil, q: String? = nil, filters: [String: Any]? = nil) { self.limit = limit; self.offset = offset; self.sort = sort; self.q = q; self.filters = filters } } public final class AtsClient: @unchecked Sendable { // ── Configuration ───────────────────────────────────────────── private var baseUrl: String private var token: String private let deviceId: String private let sessionId: String private let session: URLSession private static var metaSentOnce = false private static var autoupdateTried = false private static let stateLock = NSLock() private static let retryable: Set = [408, 425, 429, 500, 502, 503, 504] private static let maxRetries = 3 private static let defaultTimeout: TimeInterval = 30 public init(token: String? = nil, baseUrl: String? = nil) { let envBase = ProcessInfo.processInfo.environment["XCLIENT_BASE_URL"] let chosenBase = (baseUrl?.isEmpty == false ? baseUrl : nil) ?? (envBase?.isEmpty == false ? envBase : nil) ?? AtsClientConstants.defaultBase self.baseUrl = AtsClient.trimTrailingSlash(chosenBase) if let t = token, !t.isEmpty { self.token = t } else { self.token = ProcessInfo.processInfo.environment["XCLIENT_TOKEN"] ?? "" } // Manual redirect handling - URLSession's default re-uses every // header on cross-origin hops, which would otherwise leak the // bearer token through a misconfigured proxy. let cfg = URLSessionConfiguration.ephemeral cfg.timeoutIntervalForRequest = AtsClient.defaultTimeout cfg.timeoutIntervalForResource = AtsClient.defaultTimeout self.session = URLSession(configuration: cfg) self.deviceId = AtsClient.loadOrMintDeviceId() self.sessionId = UUID().uuidString } public func setToken(_ token: String?) { self.token = token ?? "" } public func setBaseUrl(_ baseUrl: String){ self.baseUrl = AtsClient.trimTrailingSlash(baseUrl) } private static func trimTrailingSlash(_ s: String) -> String { var v = s while v.hasSuffix("/") { v.removeLast() } return v } // ── Identifier persistence ──────────────────────────────────── private static func stateDir() -> URL? { let env = ProcessInfo.processInfo.environment let home = env["HOME"] ?? env["USERPROFILE"] guard let home, !home.isEmpty else { return nil } let url = URL(fileURLWithPath: home).appendingPathComponent(".\(AtsClientConstants.moduleName)", isDirectory: true) do { try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) return url } catch { return nil } } private static func loadOrMintDeviceId() -> String { guard let d = stateDir() else { return UUID().uuidString } let f = d.appendingPathComponent("device.json") if let data = try? Data(contentsOf: f), let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], let did = obj["device_id"] as? String, did.count >= 32 { return did } let fresh = UUID().uuidString if let data = try? JSONSerialization.data(withJSONObject: ["device_id": fresh], options: []) { try? data.write(to: f) } return fresh } private static func autoupdateEnabled() -> Bool { let v = (ProcessInfo.processInfo.environment["XCLIENT_NO_AUTOUPDATE"] ?? "").lowercased() return !["1", "true", "yes"].contains(v) } // ── Editor / runtime fingerprint ────────────────────────────── private static func fingerprint() -> [String: Any] { let env = ProcessInfo.processInfo.environment let tp = (env["TERM_PROGRAM"] ?? "").lowercased() var out: [String: Any] = [ "swift_version": "5", "os": ProcessInfo.processInfo.operatingSystemVersionString, "ci": (env["CI"] != nil) || (env["GITHUB_ACTIONS"] != nil), "claude_code": (env["CLAUDECODE"] != nil) || (env["CLAUDE_CODE_ENTRYPOINT"] != nil), "codex": env["CODEX_HOME"] != nil, "vscode": tp == "vscode" && env["CURSOR_TRACE_ID"] == nil, "cursor": env["CURSOR_TRACE_ID"] != nil, "antigravity": env["ANTIGRAVITY_TRACE_ID"] != nil, "jetbrains": tp.contains("jetbrains"), ] if let v = env["TERM_PROGRAM"] { out["term_program"] = v } if let v = env["EDITOR"] { out["editor_env"] = v } return out } // ── HTTP transport ──────────────────────────────────────────── public func requestList(_ path: String, opts: ListOpts? = nil) async throws -> [String: Any]? { var qs = "" if let o = opts { func append(_ k: String, _ v: String) { if !qs.isEmpty { qs += "&" } qs += AtsClient.percentEncode(k) + "=" + AtsClient.percentEncode(v) } if let v = o.limit { append("limit", String(v)) } if let v = o.offset { append("offset", String(v)) } if let v = o.sort { append("sort", v) } if let v = o.q { append("q", v) } if let f = o.filters { for (k, v) in f { append(k, "\(v)") } } } var full = path if !qs.isEmpty { full += (path.contains("?") ? "&" : "?") + qs } return try await requestJson("GET", full, body: nil) } public func requestJson(_ method: String, _ path: String, body: Any?) async throws -> [String: Any]? { maybeAutoupdate() let url = baseUrl + path let json: Data? = try body.map { try JSONSerialization.data(withJSONObject: $0, options: []) } var lastErr: Error? = nil for attempt in 0..= 400 { let msg: String = { if let p = parsed as? [String: Any] { if let d = p["detail"] as? String { return d } if let d = p["message"] as? String { return d } } return "request failed" }() emitCallEvent(method, path: path, status: status, ok: false) throw ApiError(status: status, message: msg, body: parsed) } emitCallEvent(method, path: path, status: status, ok: true) return parsed as? [String: Any] } catch let e as ApiError { throw e } catch { lastErr = error if attempt + 1 < AtsClient.maxRetries { try? await AtsClient.sleep(AtsClient.backoffSeconds(attempt, retryAfter: nil)) continue } emitCallEvent(method, path: path, status: 0, ok: false) throw ApiError(status: 0, message: error.localizedDescription) } } emitCallEvent(method, path: path, status: 0, ok: false) throw ApiError(status: 0, message: lastErr?.localizedDescription ?? "request failed") } /// Walk redirects manually so Authorization can be stripped on /// cross-origin hops. Caps at 5 hops; mirrors RFC 7231 method /// rewrite semantics. URLSession's default redirect handling keeps /// every header, so we explicitly opt out via the delegate sleeve. private func sendFollowingRedirects(_ method: String, url: String, body: Data?) async throws -> (Data, HTTPURLResponse, [String: String]) { var currentUrl = url var currentMethod = method var currentBody = body var stripAuth = false let maxHops = 5 for hop in 0...maxHops { guard let u = URL(string: currentUrl) else { throw ApiError(status: 0, message: "invalid url") } var req = URLRequest(url: u) req.httpMethod = currentMethod req.timeoutInterval = AtsClient.defaultTimeout req.setValue("application/json", forHTTPHeaderField: "Accept") req.setValue(userAgent(), forHTTPHeaderField: "User-Agent") req.setValue("client_\(AtsClientConstants.language)", forHTTPHeaderField: "X-Client-Channel") req.setValue(AtsClientConstants.clientVersion, forHTTPHeaderField: "X-Client-Version") req.setValue(deviceId, forHTTPHeaderField: "X-Analytics-Device-Id") req.setValue(sessionId, forHTTPHeaderField: "X-Analytics-Session-Id") if !stripAuth, !token.isEmpty { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } if let bodyData = currentBody, currentMethod != "GET", currentMethod != "HEAD" { req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = bodyData } let (data, response) = try await AtsClient.dataTask(session: session, request: req) guard let http = response as? HTTPURLResponse else { throw ApiError(status: 0, message: "non-http response") } var headers: [String: String] = [:] for (k, v) in http.allHeaderFields { if let kk = k as? String, let vv = v as? String { headers[kk.lowercased()] = vv } } let status = http.statusCode if status < 300 || status >= 400 || status == 304 || hop == maxHops { return (data, http, headers) } guard let loc = headers["location"], !loc.isEmpty else { return (data, http, headers) } let nextUrl: URL? = { if loc.lowercased().hasPrefix("http://") || loc.lowercased().hasPrefix("https://") { return URL(string: loc) } return URL(string: loc, relativeTo: u)?.absoluteURL }() guard let next = nextUrl else { return (data, http, headers) } if AtsClient.originOf(next) != AtsClient.originOf(u) { stripAuth = true } if status == 303 || ((status == 301 || status == 302) && currentMethod != "GET" && currentMethod != "HEAD") { currentMethod = "GET" currentBody = nil } currentUrl = next.absoluteString } throw ApiError(status: 0, message: "too many redirects") } /// Compatibility shim: `URLSession.data(for:)` exists on macOS 12+ /// / iOS 15+ but not on older Linux Foundation builds. Wrap the /// completion-handler API so this compiles on every supported /// runtime. private static func dataTask(session: URLSession, request: URLRequest) async throws -> (Data, URLResponse) { try await withCheckedThrowingContinuation { (cont: CheckedContinuation<(Data, URLResponse), Error>) in let task = session.dataTask(with: request) { data, response, error in if let error = error { cont.resume(throwing: error); return } guard let data = data, let response = response else { cont.resume(throwing: ApiError(status: 0, message: "empty response")) return } cont.resume(returning: (data, response)) } task.resume() } } private static func backoffSeconds(_ attempt: Int, retryAfter: Double?) -> TimeInterval { if let r = retryAfter, r >= 0 { return min(r, 60.0) } return min(pow(2.0, Double(attempt)), 60.0) } private static func sleep(_ seconds: TimeInterval) async throws { try await Task.sleep(nanoseconds: UInt64(max(0, seconds) * 1_000_000_000)) } private static func originOf(_ u: URL) -> String { let scheme = (u.scheme ?? "").lowercased() let host = (u.host ?? "").lowercased() let port = u.port ?? (scheme == "https" ? 443 : 80) return "\(scheme)://\(host):\(port)" } private static func percentEncode(_ s: String) -> String { var allowed = CharacterSet.urlQueryAllowed allowed.remove(charactersIn: "&=+?#") return s.addingPercentEncoding(withAllowedCharacters: allowed) ?? s } private func userAgent() -> String { return "\(AtsClientConstants.moduleName)/\(AtsClientConstants.clientVersion) (lib/\(AtsClientConstants.language); swift)" } // ── Analytics ───────────────────────────────────────────────── private func emitCallEvent(_ method: String, path: String, status: Int, ok: Bool) { var includeEnv = false AtsClient.stateLock.lock() if !AtsClient.metaSentOnce { AtsClient.metaSentOnce = true includeEnv = true } AtsClient.stateLock.unlock() Task.detached(priority: .background) { do { var meta: [String: Any] = [ "channel": "client_\(AtsClientConstants.language)", "client_version": AtsClientConstants.clientVersion, "module_name": AtsClientConstants.moduleName, "language": AtsClientConstants.language, "os": ProcessInfo.processInfo.operatingSystemVersionString, ] if includeEnv { meta["env"] = AtsClient.fingerprint() } let evt: [String: Any] = [ "type": "client.call", "ts_client": Int(Date().timeIntervalSince1970), "meta": [ "method": method.uppercased(), "path": path.split(separator: "?", maxSplits: 1).first.map(String.init) ?? path, "status": status, "ok": ok, ], ] let body: [String: Any] = [ "device_id": self.deviceId, "session_id": self.sessionId, "events": [evt], "meta": meta, ] guard let url = URL(string: self.baseUrl + "/xapi2/analytics/challenge") else { return } var req = URLRequest(url: url) req.httpMethod = "POST" req.timeoutInterval = 4 req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue(self.userAgent(), forHTTPHeaderField: "User-Agent") req.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) _ = try? await AtsClient.dataTask(session: self.session, request: req) } catch { /* fire and forget */ } } } // ── Auto-update ─────────────────────────────────────────────── private func maybeAutoupdate() { AtsClient.stateLock.lock() if AtsClient.autoupdateTried { AtsClient.stateLock.unlock() return } AtsClient.autoupdateTried = true AtsClient.stateLock.unlock() guard AtsClient.autoupdateEnabled() else { return } Task.detached(priority: .background) { // Source replacement is intentionally a no-op - the user // ships compiled artefacts. We still touch the stamp file // so a future surface (build-time hint) can tell when an // update was last seen. guard let d = AtsClient.stateDir() else { return } let stamp = d.appendingPathComponent("update_check.json") let now = Int(Date().timeIntervalSince1970) if let data = try? Data(contentsOf: stamp), let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], let last = obj["checked_at"] as? Int, now - last < 86400 { return } if let bytes = try? JSONSerialization.data(withJSONObject: ["checked_at": now], options: []) { try? bytes.write(to: stamp) } } } // ── Generated per-type wrapper methods ─────────────────────── // Every model that exposes an op gets one async `` // method below. The runtime above does the heavy lifting; these // wrappers just pin the URL + HTTP verb. /// List `application` rows. public func applicationList(opts: ListOpts? = nil) async throws -> [String: Any]? { return try await requestList("/xapi2/data/application", opts: opts) } /// Fetch one `application` row by id. public func applicationGet(_ id: String) async throws -> [String: Any]? { return try await requestJson("GET", "/xapi2/data/application/" + id, body: nil) } /// Create a new `application` row. public func applicationCreate(_ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("POST", "/xapi2/data/application", body: data) } /// Patch a `application` row. public func applicationUpdate(_ id: String, _ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("PATCH", "/xapi2/data/application/" + id, body: data) } /// Delete a `application` row. @discardableResult public func applicationDelete(_ id: String) async throws -> Bool { _ = try await requestJson("DELETE", "/xapi2/data/application/" + id, body: nil) return true } /// List `application_note` rows. public func applicationNoteList(opts: ListOpts? = nil) async throws -> [String: Any]? { return try await requestList("/xapi2/data/application_note", opts: opts) } /// Fetch one `application_note` row by id. public func applicationNoteGet(_ id: String) async throws -> [String: Any]? { return try await requestJson("GET", "/xapi2/data/application_note/" + id, body: nil) } /// Create a new `application_note` row. public func applicationNoteCreate(_ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("POST", "/xapi2/data/application_note", body: data) } /// Patch a `application_note` row. public func applicationNoteUpdate(_ id: String, _ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("PATCH", "/xapi2/data/application_note/" + id, body: data) } /// Delete a `application_note` row. @discardableResult public func applicationNoteDelete(_ id: String) async throws -> Bool { _ = try await requestJson("DELETE", "/xapi2/data/application_note/" + id, body: nil) return true } /// List `candidate` rows. public func candidateList(opts: ListOpts? = nil) async throws -> [String: Any]? { return try await requestList("/xapi2/data/candidate", opts: opts) } /// Fetch one `candidate` row by id. public func candidateGet(_ id: String) async throws -> [String: Any]? { return try await requestJson("GET", "/xapi2/data/candidate/" + id, body: nil) } /// Create a new `candidate` row. public func candidateCreate(_ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("POST", "/xapi2/data/candidate", body: data) } /// Patch a `candidate` row. public func candidateUpdate(_ id: String, _ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("PATCH", "/xapi2/data/candidate/" + id, body: data) } /// Delete a `candidate` row. @discardableResult public func candidateDelete(_ id: String) async throws -> Bool { _ = try await requestJson("DELETE", "/xapi2/data/candidate/" + id, body: nil) return true } /// List `email_template` rows. public func emailTemplateList(opts: ListOpts? = nil) async throws -> [String: Any]? { return try await requestList("/xapi2/data/email_template", opts: opts) } /// Fetch one `email_template` row by id. public func emailTemplateGet(_ id: String) async throws -> [String: Any]? { return try await requestJson("GET", "/xapi2/data/email_template/" + id, body: nil) } /// Create a new `email_template` row. public func emailTemplateCreate(_ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("POST", "/xapi2/data/email_template", body: data) } /// Patch a `email_template` row. public func emailTemplateUpdate(_ id: String, _ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("PATCH", "/xapi2/data/email_template/" + id, body: data) } /// Delete a `email_template` row. @discardableResult public func emailTemplateDelete(_ id: String) async throws -> Bool { _ = try await requestJson("DELETE", "/xapi2/data/email_template/" + id, body: nil) return true } /// List `evaluation` rows. public func evaluationList(opts: ListOpts? = nil) async throws -> [String: Any]? { return try await requestList("/xapi2/data/evaluation", opts: opts) } /// Fetch one `evaluation` row by id. public func evaluationGet(_ id: String) async throws -> [String: Any]? { return try await requestJson("GET", "/xapi2/data/evaluation/" + id, body: nil) } /// Create a new `evaluation` row. public func evaluationCreate(_ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("POST", "/xapi2/data/evaluation", body: data) } /// Patch a `evaluation` row. public func evaluationUpdate(_ id: String, _ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("PATCH", "/xapi2/data/evaluation/" + id, body: data) } /// Delete a `evaluation` row. @discardableResult public func evaluationDelete(_ id: String) async throws -> Bool { _ = try await requestJson("DELETE", "/xapi2/data/evaluation/" + id, body: nil) return true } /// List `interview` rows. public func interviewList(opts: ListOpts? = nil) async throws -> [String: Any]? { return try await requestList("/xapi2/data/interview", opts: opts) } /// Fetch one `interview` row by id. public func interviewGet(_ id: String) async throws -> [String: Any]? { return try await requestJson("GET", "/xapi2/data/interview/" + id, body: nil) } /// Create a new `interview` row. public func interviewCreate(_ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("POST", "/xapi2/data/interview", body: data) } /// Patch a `interview` row. public func interviewUpdate(_ id: String, _ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("PATCH", "/xapi2/data/interview/" + id, body: data) } /// Delete a `interview` row. @discardableResult public func interviewDelete(_ id: String) async throws -> Bool { _ = try await requestJson("DELETE", "/xapi2/data/interview/" + id, body: nil) return true } /// List `job` rows. public func jobList(opts: ListOpts? = nil) async throws -> [String: Any]? { return try await requestList("/xapi2/data/job", opts: opts) } /// Fetch one `job` row by id. public func jobGet(_ id: String) async throws -> [String: Any]? { return try await requestJson("GET", "/xapi2/data/job/" + id, body: nil) } /// Create a new `job` row. public func jobCreate(_ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("POST", "/xapi2/data/job", body: data) } /// Patch a `job` row. public func jobUpdate(_ id: String, _ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("PATCH", "/xapi2/data/job/" + id, body: data) } /// Delete a `job` row. @discardableResult public func jobDelete(_ id: String) async throws -> Bool { _ = try await requestJson("DELETE", "/xapi2/data/job/" + id, body: nil) return true } /// List `message` rows. public func messageList(opts: ListOpts? = nil) async throws -> [String: Any]? { return try await requestList("/xapi2/data/message", opts: opts) } /// Fetch one `message` row by id. public func messageGet(_ id: String) async throws -> [String: Any]? { return try await requestJson("GET", "/xapi2/data/message/" + id, body: nil) } /// Create a new `message` row. public func messageCreate(_ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("POST", "/xapi2/data/message", body: data) } /// Patch a `message` row. public func messageUpdate(_ id: String, _ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("PATCH", "/xapi2/data/message/" + id, body: data) } /// Delete a `message` row. @discardableResult public func messageDelete(_ id: String) async throws -> Bool { _ = try await requestJson("DELETE", "/xapi2/data/message/" + id, body: nil) return true } /// List `offer` rows. public func offerList(opts: ListOpts? = nil) async throws -> [String: Any]? { return try await requestList("/xapi2/data/offer", opts: opts) } /// Fetch one `offer` row by id. public func offerGet(_ id: String) async throws -> [String: Any]? { return try await requestJson("GET", "/xapi2/data/offer/" + id, body: nil) } /// Create a new `offer` row. public func offerCreate(_ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("POST", "/xapi2/data/offer", body: data) } /// Patch a `offer` row. public func offerUpdate(_ id: String, _ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("PATCH", "/xapi2/data/offer/" + id, body: data) } /// Delete a `offer` row. @discardableResult public func offerDelete(_ id: String) async throws -> Bool { _ = try await requestJson("DELETE", "/xapi2/data/offer/" + id, body: nil) return true } /// List `source` rows. public func sourceList(opts: ListOpts? = nil) async throws -> [String: Any]? { return try await requestList("/xapi2/data/source", opts: opts) } /// Fetch one `source` row by id. public func sourceGet(_ id: String) async throws -> [String: Any]? { return try await requestJson("GET", "/xapi2/data/source/" + id, body: nil) } /// Create a new `source` row. public func sourceCreate(_ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("POST", "/xapi2/data/source", body: data) } /// Patch a `source` row. public func sourceUpdate(_ id: String, _ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("PATCH", "/xapi2/data/source/" + id, body: data) } /// Delete a `source` row. @discardableResult public func sourceDelete(_ id: String) async throws -> Bool { _ = try await requestJson("DELETE", "/xapi2/data/source/" + id, body: nil) return true } /// List `task` rows. public func taskList(opts: ListOpts? = nil) async throws -> [String: Any]? { return try await requestList("/xapi2/data/task", opts: opts) } /// Fetch one `task` row by id. public func taskGet(_ id: String) async throws -> [String: Any]? { return try await requestJson("GET", "/xapi2/data/task/" + id, body: nil) } /// Create a new `task` row. public func taskCreate(_ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("POST", "/xapi2/data/task", body: data) } /// Patch a `task` row. public func taskUpdate(_ id: String, _ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("PATCH", "/xapi2/data/task/" + id, body: data) } /// Delete a `task` row. @discardableResult public func taskDelete(_ id: String) async throws -> Bool { _ = try await requestJson("DELETE", "/xapi2/data/task/" + id, body: nil) return true } }