// 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 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 func projects() async throws -> ProjectsResponse 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 /// 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 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 projects() async throws -> ProjectsResponse { try await call("GET", ["v1", "projects"]) } 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 unpair() async throws { try await callIgnoringBody("DELETE", ["v1", "device"], body: nil as SendMessageRequest?) } // MARK: - Plumbing private func call(_ method: String, _ path: [String]) async throws -> Response { try await call(method, path, body: nil as SendMessageRequest?) } private func call( _ method: String, _ path: [String], body: Body? ) async throws -> Response { let data = try await perform(method, path, 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], body: Body?) async throws -> Data { // appending(component:) percent-encodes each segment, slashes included. let url = path.reduce(baseURL) { $0.appending(component: $1) } 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 } }