// Drop-in Swift client library for the Production Board HTTP API. // // Save this file alongside your code as `ProdClient.swift` and use // the ProdClient class: // // let c = ProdClient(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 ProdClient. 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 ProdClientConstants { public static let appSlug = "prod" public static let appName = "Production Board" public static let moduleName = "prod_client" public static let clientVersion = "0.3.13" public static let language = "swift" public static let defaultBase = "https://produktions-management-board.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 = #"{"board":{"ops":["list","read","create","update","delete"],"create_fields":["name","description","accent","settings","tags","columns"],"update_fields":["name","description","accent","settings","tags","columns"],"allowed_filters":["data__name","data__accent","data__tags","status","is_archived","owned_by"],"allowed_sorts":["created_at","updated_at","data__name"],"default_sort":"created_at","max_limit":50,"fields":[{"name":"name","type":"string","max_len":200},{"name":"tags","type":"tags"},{"name":"accent","type":"enum","values":["slate","gray","blue","indigo","violet","fuchsia","amber","orange","emerald","green","rose","red"]},{"name":"settings","type":"dict"},{"name":"description","type":"string","max_len":2000}]},"card":{"ops":["list","read","create","update","delete"],"create_fields":["title","description","status","position","priority","tags","assignee","due_date","board_id"],"update_fields":["title","description","status","position","priority","tags","assignee","due_date","board_id"],"allowed_filters":["data__status","data__priority","data__tags","data__assignee","data__board_id","status","is_archived","owned_by"],"allowed_sorts":["created_at","updated_at","data__position","data__status","data__priority","data__due_date"],"default_sort":"data__position","max_limit":200,"fields":[{"name":"tags","type":"tags"},{"name":"title","type":"string","max_len":200},{"name":"status","type":"string","max_len":64},{"name":"assignee","type":"string","max_len":64},{"name":"board_id","type":"string","max_len":64,"ref":{"type":"board","owned":true,"optional":true}},{"name":"due_date","type":"string","max_len":32},{"name":"position","type":"number"},{"name":"priority","type":"enum","values":["low","medium","high","critical"]},{"name":"description","type":"string","max_len":4000}]}}"# } 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 ProdClient: @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) ?? ProdClientConstants.defaultBase self.baseUrl = ProdClient.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 = ProdClient.defaultTimeout cfg.timeoutIntervalForResource = ProdClient.defaultTimeout self.session = URLSession(configuration: cfg) self.deviceId = ProdClient.loadOrMintDeviceId() self.sessionId = UUID().uuidString } public func setToken(_ token: String?) { self.token = token ?? "" } public func setBaseUrl(_ baseUrl: String){ self.baseUrl = ProdClient.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(".\(ProdClientConstants.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 += ProdClient.percentEncode(k) + "=" + ProdClient.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 < ProdClient.maxRetries { try? await ProdClient.sleep(ProdClient.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 = ProdClient.defaultTimeout req.setValue("application/json", forHTTPHeaderField: "Accept") req.setValue(userAgent(), forHTTPHeaderField: "User-Agent") req.setValue("client_\(ProdClientConstants.language)", forHTTPHeaderField: "X-Client-Channel") req.setValue(ProdClientConstants.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 ProdClient.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 ProdClient.originOf(next) != ProdClient.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 "\(ProdClientConstants.moduleName)/\(ProdClientConstants.clientVersion) (lib/\(ProdClientConstants.language); swift)" } // ── Analytics ───────────────────────────────────────────────── private func emitCallEvent(_ method: String, path: String, status: Int, ok: Bool) { var includeEnv = false ProdClient.stateLock.lock() if !ProdClient.metaSentOnce { ProdClient.metaSentOnce = true includeEnv = true } ProdClient.stateLock.unlock() Task.detached(priority: .background) { do { var meta: [String: Any] = [ "channel": "client_\(ProdClientConstants.language)", "client_version": ProdClientConstants.clientVersion, "module_name": ProdClientConstants.moduleName, "language": ProdClientConstants.language, "os": ProcessInfo.processInfo.operatingSystemVersionString, ] if includeEnv { meta["env"] = ProdClient.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 ProdClient.dataTask(session: self.session, request: req) } catch { /* fire and forget */ } } } // ── Auto-update ─────────────────────────────────────────────── private func maybeAutoupdate() { ProdClient.stateLock.lock() if ProdClient.autoupdateTried { ProdClient.stateLock.unlock() return } ProdClient.autoupdateTried = true ProdClient.stateLock.unlock() guard ProdClient.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 = ProdClient.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 `board` rows. public func boardList(opts: ListOpts? = nil) async throws -> [String: Any]? { return try await requestList("/xapi2/data/board", opts: opts) } /// Fetch one `board` row by id. public func boardGet(_ id: String) async throws -> [String: Any]? { return try await requestJson("GET", "/xapi2/data/board/" + id, body: nil) } /// Create a new `board` row. public func boardCreate(_ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("POST", "/xapi2/data/board", body: data) } /// Patch a `board` row. public func boardUpdate(_ id: String, _ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("PATCH", "/xapi2/data/board/" + id, body: data) } /// Delete a `board` row. @discardableResult public func boardDelete(_ id: String) async throws -> Bool { _ = try await requestJson("DELETE", "/xapi2/data/board/" + id, body: nil) return true } /// List `card` rows. public func cardList(opts: ListOpts? = nil) async throws -> [String: Any]? { return try await requestList("/xapi2/data/card", opts: opts) } /// Fetch one `card` row by id. public func cardGet(_ id: String) async throws -> [String: Any]? { return try await requestJson("GET", "/xapi2/data/card/" + id, body: nil) } /// Create a new `card` row. public func cardCreate(_ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("POST", "/xapi2/data/card", body: data) } /// Patch a `card` row. public func cardUpdate(_ id: String, _ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("PATCH", "/xapi2/data/card/" + id, body: data) } /// Delete a `card` row. @discardableResult public func cardDelete(_ id: String) async throws -> Bool { _ = try await requestJson("DELETE", "/xapi2/data/card/" + id, body: nil) return true } }