257 lines
11 KiB
Swift
257 lines
11 KiB
Swift
// 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
|
|
/// Finished threads snoozed for later, the soonest to come back first.
|
|
func snoozed() async throws -> RecentResponse
|
|
/// Hides a finished thread from Recent until `until`, at most 30 days away.
|
|
func snooze(hostId: String, threadId: String, until: Date) async throws
|
|
/// Puts a snoozed thread back in Recent now.
|
|
func unsnooze(hostId: String, threadId: String) async throws
|
|
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
|
|
/// Thread titles and messages on every online host matching `query`, which needs two characters.
|
|
func search(_ query: String) async throws -> SearchResponse
|
|
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 snoozed() async throws -> RecentResponse {
|
|
try await call("GET", ["v1", "snoozed"])
|
|
}
|
|
|
|
func snooze(hostId: String, threadId: String, until: Date) async throws {
|
|
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "snooze"], body: SnoozeRequest(until: until))
|
|
}
|
|
|
|
func unsnooze(hostId: String, threadId: String) async throws {
|
|
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "unsnooze"], body: nil as SendMessageRequest?)
|
|
}
|
|
|
|
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 search(_ query: String) async throws -> SearchResponse {
|
|
try await call("GET", ["v1", "search"], query: [URLQueryItem(name: "q", value: query)])
|
|
}
|
|
|
|
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
|
|
|
|
/// A failed request as the app sees it. A request cancelled because its task was, as when a
|
|
/// screen's `.task` ends or a refresh restarts, is a `CancellationError`, which no screen shows.
|
|
static func transportError(_ error: URLError) -> any Error {
|
|
error.code == .cancelled ? CancellationError() : GatewayError.transport(error.localizedDescription)
|
|
}
|
|
|
|
private func call<Response: Decodable>(
|
|
_ method: String, _ path: [String], query: [URLQueryItem] = []
|
|
) async throws -> Response {
|
|
try await call(method, path, query: query, body: nil as SendMessageRequest?)
|
|
}
|
|
|
|
private func call<Body: Encodable, Response: Decodable>(
|
|
_ 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<Body: Encodable>(_ method: String, _ path: [String], body: Body?) async throws {
|
|
_ = try await perform(method, path, body: body)
|
|
}
|
|
|
|
private func perform<Body: Encodable>(
|
|
_ 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 Self.transportError(error)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|