// Drop-in Swift client library for the Sourdough Tracker HTTP API. // // Save this file alongside your code as `SourdoughClient.swift` and use // the SourdoughClient class: // // let c = SourdoughClient(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 SourdoughClient. 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 SourdoughClientConstants { public static let appSlug = "sourdough" public static let appName = "Sourdough Tracker" public static let moduleName = "sourdough_client" public static let clientVersion = "0.3.13" public static let language = "swift" public static let defaultBase = "https://sourdoughtracker.com" /// 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 = #"{"log_entry":{"ops":["list","read","create","update","delete"],"create_fields":["parent_id","kind","title","body","occurred_at","rise_pct","aroma_score","bubble_activity","hydration_pct","flour_used","water_temp_c","ambient_temp_c","feeding_ratio","bake_recipe","bake_outcome","loaf_count","tags"],"update_fields":["kind","title","body","occurred_at","rise_pct","aroma_score","bubble_activity","hydration_pct","flour_used","water_temp_c","ambient_temp_c","feeding_ratio","bake_recipe","bake_outcome","loaf_count","tags"],"allowed_filters":["data__parent_id","data__kind","data__bake_outcome","status","is_archived","owned_by"],"allowed_sorts":["data__occurred_at","created_at","updated_at"],"default_sort":"data__occurred_at","max_limit":200,"fields":[{"name":"body","type":"string","max_len":8000},{"name":"kind","type":"enum","values":["feed","bake","observation","milestone","photo"]},{"name":"tags","type":"tags"},{"name":"title","type":"string","max_len":200},{"name":"rise_pct","type":"number"},{"name":"parent_id","type":"string","max_len":64,"ref":{"type":"sourdough","owned":true,"optional":false}},{"name":"flour_used","type":"string","max_len":200},{"name":"loaf_count","type":"number"},{"name":"aroma_score","type":"number"},{"name":"bake_recipe","type":"string","max_len":4000},{"name":"occurred_at","type":"string","max_len":32},{"name":"bake_outcome","type":"enum","values":["amazing","great","ok","dense","flat","undercooked","overcooked"]},{"name":"water_temp_c","type":"number"},{"name":"feeding_ratio","type":"string","max_len":32},{"name":"hydration_pct","type":"number"},{"name":"ambient_temp_c","type":"number"},{"name":"bubble_activity","type":"number"}]},"sourdough":{"ops":["list","read","create","update","delete"],"create_fields":["name","slug","source","started_at","flour_type","hydration_pct","feeding_ratio","feeding_freq_hours","ambient_temp_c","last_fed_at","retired","favorite","tags","color","notes"],"update_fields":["name","slug","source","started_at","flour_type","hydration_pct","feeding_ratio","feeding_freq_hours","ambient_temp_c","last_fed_at","retired","favorite","tags","color","notes"],"allowed_filters":["data__name","data__slug","data__flour_type","data__retired","data__favorite","data__tags","status","is_archived","owned_by"],"allowed_sorts":["created_at","updated_at","data__name","data__started_at","data__last_fed_at"],"default_sort":"data__name","max_limit":200,"fields":[{"name":"mode","type":"enum","values":["establishing","maintenance","activating","baking"]},{"name":"name","type":"string","max_len":120},{"name":"slug","type":"string","max_len":120},{"name":"tags","type":"tags"},{"name":"color","type":"string","max_len":24},{"name":"notes","type":"string","max_len":8000},{"name":"source","type":"string","max_len":200},{"name":"retired","type":"bool"},{"name":"favorite","type":"bool"},{"name":"flour_type","type":"enum","values":["white","whole_wheat","rye","spelt","einkorn","khorasan","mixed","other"]},{"name":"started_at","type":"string","max_len":32},{"name":"last_fed_at","type":"string","max_len":32},{"name":"establish_day","type":"number"},{"name":"feeding_ratio","type":"string","max_len":32},{"name":"hydration_pct","type":"number"},{"name":"ambient_temp_c","type":"number"},{"name":"computed_state","type":"string","max_len":64},{"name":"bake_target_date","type":"string","max_len":32},{"name":"feeding_freq_hours","type":"number"},{"name":"establish_started_at","type":"string","max_len":32},{"name":"establish_step_done_at","type":"string","max_len":64},{"name":"establish_next_remind_at","type":"string","max_len":64}]}}"# } 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 SourdoughClient: @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) ?? SourdoughClientConstants.defaultBase self.baseUrl = SourdoughClient.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 = SourdoughClient.defaultTimeout cfg.timeoutIntervalForResource = SourdoughClient.defaultTimeout self.session = URLSession(configuration: cfg) self.deviceId = SourdoughClient.loadOrMintDeviceId() self.sessionId = UUID().uuidString } public func setToken(_ token: String?) { self.token = token ?? "" } public func setBaseUrl(_ baseUrl: String){ self.baseUrl = SourdoughClient.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(".\(SourdoughClientConstants.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 += SourdoughClient.percentEncode(k) + "=" + SourdoughClient.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 < SourdoughClient.maxRetries { try? await SourdoughClient.sleep(SourdoughClient.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 = SourdoughClient.defaultTimeout req.setValue("application/json", forHTTPHeaderField: "Accept") req.setValue(userAgent(), forHTTPHeaderField: "User-Agent") req.setValue("client_\(SourdoughClientConstants.language)", forHTTPHeaderField: "X-Client-Channel") req.setValue(SourdoughClientConstants.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 SourdoughClient.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 SourdoughClient.originOf(next) != SourdoughClient.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 "\(SourdoughClientConstants.moduleName)/\(SourdoughClientConstants.clientVersion) (lib/\(SourdoughClientConstants.language); swift)" } // ── Analytics ───────────────────────────────────────────────── private func emitCallEvent(_ method: String, path: String, status: Int, ok: Bool) { var includeEnv = false SourdoughClient.stateLock.lock() if !SourdoughClient.metaSentOnce { SourdoughClient.metaSentOnce = true includeEnv = true } SourdoughClient.stateLock.unlock() Task.detached(priority: .background) { do { var meta: [String: Any] = [ "channel": "client_\(SourdoughClientConstants.language)", "client_version": SourdoughClientConstants.clientVersion, "module_name": SourdoughClientConstants.moduleName, "language": SourdoughClientConstants.language, "os": ProcessInfo.processInfo.operatingSystemVersionString, ] if includeEnv { meta["env"] = SourdoughClient.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 SourdoughClient.dataTask(session: self.session, request: req) } catch { /* fire and forget */ } } } // ── Auto-update ─────────────────────────────────────────────── private func maybeAutoupdate() { SourdoughClient.stateLock.lock() if SourdoughClient.autoupdateTried { SourdoughClient.stateLock.unlock() return } SourdoughClient.autoupdateTried = true SourdoughClient.stateLock.unlock() guard SourdoughClient.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 = SourdoughClient.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 `log_entry` rows. public func logEntryList(opts: ListOpts? = nil) async throws -> [String: Any]? { return try await requestList("/xapi2/data/log_entry", opts: opts) } /// Fetch one `log_entry` row by id. public func logEntryGet(_ id: String) async throws -> [String: Any]? { return try await requestJson("GET", "/xapi2/data/log_entry/" + id, body: nil) } /// Create a new `log_entry` row. public func logEntryCreate(_ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("POST", "/xapi2/data/log_entry", body: data) } /// Patch a `log_entry` row. public func logEntryUpdate(_ id: String, _ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("PATCH", "/xapi2/data/log_entry/" + id, body: data) } /// Delete a `log_entry` row. @discardableResult public func logEntryDelete(_ id: String) async throws -> Bool { _ = try await requestJson("DELETE", "/xapi2/data/log_entry/" + id, body: nil) return true } /// List `sourdough` rows. public func sourdoughList(opts: ListOpts? = nil) async throws -> [String: Any]? { return try await requestList("/xapi2/data/sourdough", opts: opts) } /// Fetch one `sourdough` row by id. public func sourdoughGet(_ id: String) async throws -> [String: Any]? { return try await requestJson("GET", "/xapi2/data/sourdough/" + id, body: nil) } /// Create a new `sourdough` row. public func sourdoughCreate(_ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("POST", "/xapi2/data/sourdough", body: data) } /// Patch a `sourdough` row. public func sourdoughUpdate(_ id: String, _ data: [String: Any]) async throws -> [String: Any]? { return try await requestJson("PATCH", "/xapi2/data/sourdough/" + id, body: data) } /// Delete a `sourdough` row. @discardableResult public func sourdoughDelete(_ id: String) async throws -> Bool { _ = try await requestJson("DELETE", "/xapi2/data/sourdough/" + id, body: nil) return true } }