// Berth Swift client 1.0.0 · https://atberth.com/docs/sdk/ · MIT // Drop this file into an iOS 15+ / macOS 12+ project. No dependencies. // // let berth = Berth(app: "my-app", key: "bpk_...") // let user = try await berth.auth.signIn(email: e, password: p) // let notes: [Note] = try await berth.from("notes").eq("done", "false").order("created_at", .desc).limit(20).rows() // try await berth.from("notes").insert(Note(title: "Hi")) // for try await event in berth.from("notes").changes() { print(event.type, event.record) } // let room = berth.channel("room:1", presence: ["name": "Ana"]) // for try await message in room.messages() { ... }; try await room.send("typing", ["on": true]) import Foundation public struct BerthError: Error, LocalizedError { public let status: Int, code: String?, message: String public var errorDescription: String? { message } } public struct BerthSession: Codable { public var access_token: String public var refresh_token: String public var user: [String: AnyCodable]? } public final class Berth: @unchecked Sendable { public let base: URL public let key: String let defaultsKey: String var session: BerthSession? { didSet { if let s = session, let data = try? JSONEncoder().encode(s) { UserDefaults.standard.set(data, forKey: defaultsKey) } else { UserDefaults.standard.removeObject(forKey: defaultsKey) } } } public lazy var auth = Auth(self) public init(app: String, key: String, url: String = "https://api.atberth.com/v1") { self.base = URL(string: url)!.appendingPathComponent("apps").appendingPathComponent(app) self.key = key self.defaultsKey = "berth.session.\(app)" if let data = UserDefaults.standard.data(forKey: defaultsKey) { session = try? JSONDecoder().decode(BerthSession.self, from: data) } } func url(_ path: String, _ query: [URLQueryItem] = []) -> URL { var c = URLComponents(url: base.appendingPathComponent(path), resolvingAgainstBaseURL: false)! if !query.isEmpty { c.queryItems = query } return c.url! } func request(_ method: String, _ path: String, query: [URLQueryItem] = [], body: Data? = nil, contentType: String = "application/json", retried: Bool = false) async throws -> Data { var r = URLRequest(url: url(path, query)) r.httpMethod = method r.setValue(key, forHTTPHeaderField: "apikey") if let t = session?.access_token { r.setValue("Bearer \(t)", forHTTPHeaderField: "Authorization") } if let body { r.httpBody = body; r.setValue(contentType, forHTTPHeaderField: "Content-Type") } let (data, resp) = try await URLSession.shared.data(for: r) let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 if status == 401, !retried, session != nil, try await auth.refresh() { return try await request(method, path, query: query, body: body, contentType: contentType, retried: true) } guard (200..<300).contains(status) else { let j = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] throw BerthError(status: status, code: j?["error"] as? String, message: j?["message"] as? String ?? "HTTP \(status)") } return data } func json(_ value: Any) throws -> Data { try JSONSerialization.data(withJSONObject: value) } public func from(_ table: String) -> Query { Query(self, table) } public func channel(_ name: String, presence: [String: Any]? = nil) -> Channel { Channel(self, name, presence) } /// Call a Postgres function you allowed for users (PUT /rpc/{fn}/access). public func rpc(_ fn: String, _ args: [String: Any] = [:], as: T.Type = T.self) async throws -> T { try JSONDecoder().decode(T.self, from: try await request("POST", "rpc/\(fn)", body: json(args))) } public func upload(bucket: String, key: String, data: Data, contentType: String = "application/octet-stream") async throws { _ = try await request("PUT", "storage/objects/\(bucket)/\(key)", body: data, contentType: contentType) } public func download(bucket: String, key: String) async throws -> Data { try await request("GET", "storage/objects/\(bucket)/\(key)") } /// Server-Sent Events from a path, as (event name, JSON data). func events(_ path: String, _ query: [URLQueryItem]) -> AsyncThrowingStream<(String, Data), Error> { AsyncThrowingStream { cont in let task = Task { var q = query + [URLQueryItem(name: "apikey", value: key)] if let t = session?.access_token { q.append(URLQueryItem(name: "access_token", value: t)) } var r = URLRequest(url: url(path, q)) r.setValue("text/event-stream", forHTTPHeaderField: "Accept") r.timeoutInterval = 3600 do { let (bytes, _) = try await URLSession.shared.bytes(for: r) var event = "message", data = "" for try await line in bytes.lines { if line.isEmpty { if !data.isEmpty { cont.yield((event, Data(data.utf8))) }; event = "message"; data = ""; continue } if line.hasPrefix("event:") { event = line.dropFirst(6).trimmingCharacters(in: .whitespaces) } else if line.hasPrefix("data:") { data += line.dropFirst(5).trimmingCharacters(in: .whitespaces) } } cont.finish() } catch { cont.finish(throwing: error) } } cont.onTermination = { _ in task.cancel() } } } public final class Auth { unowned let b: Berth init(_ b: Berth) { self.b = b } public var isSignedIn: Bool { b.session != nil } @discardableResult func start(_ path: String, _ body: [String: Any]) async throws -> [String: AnyCodable]? { let data = try await b.request("POST", path, body: try b.json(body)) b.session = try JSONDecoder().decode(BerthSession.self, from: data) return b.session?.user } @discardableResult public func signUp(email: String, password: String) async throws -> [String: AnyCodable]? { try await start("auth/signup", ["email": email, "password": password]) } @discardableResult public func signIn(email: String, password: String) async throws -> [String: AnyCodable]? { try await start("auth/login", ["email": email, "password": password]) } public func sendCode(email: String) async throws { _ = try await b.request("POST", "auth/code", body: try b.json(["email": email])) } @discardableResult public func verify(email: String, code: String) async throws -> [String: AnyCodable]? { try await start("auth/verify", ["email": email, "code": code]) } /// Sign in with Apple: pass the identityToken string (and nonce if you used one). @discardableResult public func signInWithApple(idToken: String, nonce: String? = nil) async throws -> [String: AnyCodable]? { var body: [String: Any] = ["id_token": idToken]; if let nonce { body["nonce"] = nonce } return try await start("auth/apple", body) } public func refresh() async throws -> Bool { guard let r = b.session?.refresh_token else { return false } var req = URLRequest(url: b.url("auth/refresh")); req.httpMethod = "POST" req.setValue(b.key, forHTTPHeaderField: "apikey"); req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try b.json(["refresh_token": r]) let (data, resp) = try await URLSession.shared.data(for: req) guard (resp as? HTTPURLResponse)?.statusCode == 200 else { b.session = nil; return false } b.session = try JSONDecoder().decode(BerthSession.self, from: data) return true } public func signOut() async { if let r = b.session?.refresh_token { _ = try? await b.request("POST", "auth/logout", body: try b.json(["refresh_token": r])) }; b.session = nil } } public enum Order: String { case asc, desc } public final class Query { let b: Berth, table: String var q: [URLQueryItem] = [] init(_ b: Berth, _ table: String) { self.b = b; self.table = table } public func filter(_ col: String, _ op: String, _ v: String) -> Query { q.append(URLQueryItem(name: col, value: "\(op).\(v)")); return self } public func eq(_ c: String, _ v: String) -> Query { filter(c, "eq", v) } public func neq(_ c: String, _ v: String) -> Query { filter(c, "neq", v) } public func gt(_ c: String, _ v: String) -> Query { filter(c, "gt", v) } public func lt(_ c: String, _ v: String) -> Query { filter(c, "lt", v) } public func order(_ c: String, _ d: Order = .asc) -> Query { q.append(URLQueryItem(name: "order", value: "\(c).\(d.rawValue)")); return self } public func limit(_ n: Int) -> Query { q.append(URLQueryItem(name: "limit", value: String(n))); return self } public func rows(_ type: T.Type = T.self) async throws -> [T] { try JSONDecoder().decode(BerthPage.self, from: try await b.request("GET", "tables/\(table)/rows", query: q)).rows } public func insert(_ row: T) async throws { _ = try await b.request("POST", "tables/\(table)/rows", body: try JSONEncoder().encode(row)) } public func update(id: String, _ values: [String: Any]) async throws { _ = try await b.request("PATCH", "tables/\(table)/rows/\(id)", body: try b.json(values)) } public func delete(id: String) async throws { _ = try await b.request("DELETE", "tables/\(table)/rows/\(id)") } public struct Change: Decodable { public let type: String; public let table: String; public let record: [String: AnyCodable]?; public let old_record: [String: AnyCodable]? } /// Live inserts, updates and deletes the signed-in user is allowed to see. public func changes() -> AsyncThrowingStream { let source = b.events("tables/\(table)/stream", []) return AsyncThrowingStream { cont in let t = Task { do { for try await (name, data) in source where ["insert", "update", "delete"].contains(name) { if let c = try? JSONDecoder().decode(Change.self, from: data) { cont.yield(c) } }; cont.finish() } catch { cont.finish(throwing: error) } } cont.onTermination = { _ in t.cancel() } } } } public final class Channel { let b: Berth, name: String, presence: [String: Any]? public private(set) var clientId: String? init(_ b: Berth, _ name: String, _ presence: [String: Any]?) { self.b = b; self.name = name; self.presence = presence } public struct Event { public let kind: String; public let data: [String: AnyCodable] } /// broadcast, presence_sync, presence_join, presence_leave and presence_update events. public func messages() -> AsyncThrowingStream { var q: [URLQueryItem] = [] if let presence, let d = try? JSONSerialization.data(withJSONObject: presence) { q.append(URLQueryItem(name: "presence", value: String(data: d, encoding: .utf8))) } let source = b.events("channels/\(name)/stream", q) return AsyncThrowingStream { cont in let t = Task { do { for try await (kind, data) in source { let obj = (try? JSONDecoder().decode([String: AnyCodable].self, from: data)) ?? [:] if kind == "hello" { self.clientId = obj["client_id"]?.value as? String; continue } cont.yield(Event(kind: kind, data: obj)) }; cont.finish() } catch { cont.finish(throwing: error) } } cont.onTermination = { _ in t.cancel() } } } public func send(_ event: String, _ payload: [String: Any]) async throws { var body: [String: Any] = ["event": event, "payload": payload]; if let clientId { body["client_id"] = clientId } _ = try await b.request("POST", "channels/\(name)/broadcast", body: try b.json(body)) } public func track(_ state: [String: Any]) async throws { guard let clientId else { return } _ = try await b.request("POST", "channels/\(name)/presence", body: try b.json(["client_id": clientId, "state": state])) } } } struct BerthPage: Decodable { let rows: [R] } /// Any JSON value, for user records and event payloads. public struct AnyCodable: Codable { public let value: Any public init(_ v: Any) { value = v } public init(from d: Decoder) throws { let c = try d.singleValueContainer() if c.decodeNil() { value = NSNull() } else if let v = try? c.decode(Bool.self) { value = v } else if let v = try? c.decode(Int.self) { value = v } else if let v = try? c.decode(Double.self) { value = v } else if let v = try? c.decode(String.self) { value = v } else if let v = try? c.decode([AnyCodable].self) { value = v.map(\.value) } else if let v = try? c.decode([String: AnyCodable].self) { value = v.mapValues(\.value) } else { value = NSNull() } } public func encode(to e: Encoder) throws { var c = e.singleValueContainer() switch value { case let v as Bool: try c.encode(v); case let v as Int: try c.encode(v); case let v as Double: try c.encode(v) case let v as String: try c.encode(v); case let v as [Any]: try c.encode(v.map(AnyCodable.init)) case let v as [String: Any]: try c.encode(v.mapValues(AnyCodable.init)); default: try c.encodeNil() } } }