// HTTPS + JSON calls to the WristCode gateway. docs/api.md lists every route. import Foundation enum GatewayError: LocalizedError, Equatable { /// 401: the device token is missing or revoked. The app shows the pairing screen. case unauthorized /// Any other non-2xx status, with the gateway's `{ "error": ... }` message when it sent one. case http(status: Int, message: String?) case transport(String) case decoding(String) var errorDescription: String? { switch self { case .unauthorized: "This watch is no longer paired." case let .http(status, message): message ?? Self.defaultMessage(status) case let .transport(message): message case let .decoding(message): "The gateway sent something unexpected. \(message)" } } /// True for 409, when the request was stale, such as an approval someone already answered. var isStale: Bool { if case .http(409, _) = self { return true } return false } private static func defaultMessage(_ status: Int) -> String { switch status { case 404: "Not found." case 409: "Already handled." case 429: "Too many attempts. Wait a minute." case 503: "That host is offline." default: "The gateway returned \(status)." } } } /// Everything the watch asks of a gateway. `GatewayClient` talks to the real one and `DemoGateway` /// serves fixtures, so every screen works without a live gateway. protocol GatewayAPI: Sendable { func feed() async throws -> Feed func summary() async throws -> Summary func thread(hostId: String, threadId: String) async throws -> ThreadDetail /// The page of messages before `cursor`, a thread's `earlierCursor` or the last page's. func history(hostId: String, threadId: String, cursor: String) async throws -> HistoryResponse func sendMessage(hostId: String, threadId: String, text: String) async throws func respond(hostId: String, threadId: String, request: RespondRequest) async throws func stop(hostId: String, threadId: String) async throws /// Settles a thread, as in T3. A settled thread that finished or failed leaves the feed. func settle(hostId: String, threadId: String) async throws /// Turns Ask before acting on or off for a thread. func setAskFirst(hostId: String, threadId: String, askFirst: Bool) async throws /// Every unsettled finished thread, including those past the feed's first 10. func recent() async throws -> RecentResponse /// Settles every completed thread in Recent. Failed threads stay. func settleRecent() async throws -> SettleRecentResponse func projects() async throws -> ProjectsResponse /// A project's newest 50 threads, settled ones included. Subagents are left out. func projectThreads(hostId: String, projectId: String) async throws -> ThreadListResponse func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse func registerPush(_ registration: PushRegistration) async throws func status() async throws -> StatusResponse /// Connects the host again from its first route. func reconnect(hostId: String) async throws func testPush() async throws -> PushResult /// Turns one kind of push on or off for this watch, and returns all four switches. func setNotification(_ key: NotificationPrefs.Key, _ on: Bool) async throws -> NotificationPrefs /// Revokes this watch's device token. func unpair() async throws } struct GatewayClient: GatewayAPI { let baseURL: URL let token: String? var session: URLSession = .shared /// `POST /v1/pair`. The only call made without a device token. static func pair(baseURL: URL, code: String, deviceName: String) async throws -> PairResponse { let client = GatewayClient(baseURL: baseURL, token: nil) return try await client.call("POST", ["v1", "pair"], body: PairRequest(code: code, deviceName: deviceName)) } func feed() async throws -> Feed { try await call("GET", ["v1", "feed"]) } func summary() async throws -> Summary { try await call("GET", ["v1", "summary"]) } func thread(hostId: String, threadId: String) async throws -> ThreadDetail { try await call("GET", ["v1", "threads", hostId, threadId]) } func history(hostId: String, threadId: String, cursor: String) async throws -> HistoryResponse { try await call("GET", ["v1", "threads", hostId, threadId, "history"], query: [URLQueryItem(name: "cursor", value: cursor)]) } func sendMessage(hostId: String, threadId: String, text: String) async throws { try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "messages"], body: SendMessageRequest(text: text)) } func respond(hostId: String, threadId: String, request: RespondRequest) async throws { try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "respond"], body: request) } func stop(hostId: String, threadId: String) async throws { try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "stop"], body: nil as SendMessageRequest?) } func settle(hostId: String, threadId: String) async throws { try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "settle"], body: nil as SendMessageRequest?) } func setAskFirst(hostId: String, threadId: String, askFirst: Bool) async throws { try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "mode"], body: ThreadModeRequest(askFirst: askFirst)) } func recent() async throws -> RecentResponse { try await call("GET", ["v1", "recent"]) } func settleRecent() async throws -> SettleRecentResponse { try await call("POST", ["v1", "recent", "settle"]) } func projects() async throws -> ProjectsResponse { try await call("GET", ["v1", "projects"]) } func projectThreads(hostId: String, projectId: String) async throws -> ThreadListResponse { try await call("GET", ["v1", "projects", hostId, projectId, "threads"]) } func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse { try await call("POST", ["v1", "threads"], body: request) } func registerPush(_ registration: PushRegistration) async throws { try await callIgnoringBody("PUT", ["v1", "device", "push"], body: registration) } func status() async throws -> StatusResponse { try await call("GET", ["v1", "status"]) } func reconnect(hostId: String) async throws { try await callIgnoringBody("POST", ["v1", "hosts", hostId, "reconnect"], body: nil as SendMessageRequest?) } func testPush() async throws -> PushResult { try await call("POST", ["v1", "device", "test-push"]) } func setNotification(_ key: NotificationPrefs.Key, _ on: Bool) async throws -> NotificationPrefs { try await call("PUT", ["v1", "device", "notifications"], body: [key.rawValue: on]) } func unpair() async throws { try await callIgnoringBody("DELETE", ["v1", "device"], body: nil as SendMessageRequest?) } // MARK: - Plumbing private func call( _ method: String, _ path: [String], query: [URLQueryItem] = [] ) async throws -> Response { try await call(method, path, query: query, body: nil as SendMessageRequest?) } private func call( _ method: String, _ path: [String], query: [URLQueryItem] = [], body: Body? ) async throws -> Response { let data = try await perform(method, path, query: query, body: body) do { return try JSON.decoder().decode(Response.self, from: data) } catch { throw GatewayError.decoding(String(describing: error)) } } private func callIgnoringBody(_ method: String, _ path: [String], body: Body?) async throws { _ = try await perform(method, path, body: body) } private func perform( _ method: String, _ path: [String], query: [URLQueryItem] = [], body: Body? ) async throws -> Data { // appending(component:) percent-encodes each segment, slashes included. var url = path.reduce(baseURL) { $0.appending(component: $1) } if !query.isEmpty { url.append(queryItems: query) } var request = URLRequest(url: url, timeoutInterval: 20) request.httpMethod = method request.setValue("application/json", forHTTPHeaderField: "Accept") if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } if let body { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSON.encoder().encode(body) } let data: Data let response: URLResponse do { (data, response) = try await session.data(for: request) } catch let error as URLError { throw GatewayError.transport(error.localizedDescription) } guard let http = response as? HTTPURLResponse else { throw GatewayError.transport("No HTTP response.") } guard (200 ..< 300).contains(http.statusCode) else { if http.statusCode == 401 { throw GatewayError.unauthorized } let message = try? JSON.decoder().decode(ErrorResponse.self, from: data).error throw GatewayError.http(status: http.statusCode, message: message) } return data } }