A gear on the feed opens a status overview that leads with what is wrong: offline hosts, hosts on a fallback route, tokens expiring within 7 days, and push problems, with healthy hosts folded into one row. Each host opens its route ladder with Reconnect. The notifications page shows registration, the last push, Send test and Unpair. Demo mode gains a Laptop on its VPN fallback, and snapshot tests render the three screens.
180 lines
6.9 KiB
Swift
180 lines
6.9 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
|
|
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<Response: Decodable>(_ method: String, _ path: [String]) async throws -> Response {
|
|
try await call(method, path, body: nil as SendMessageRequest?)
|
|
}
|
|
|
|
private func call<Body: Encodable, Response: Decodable>(
|
|
_ 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<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], 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
|
|
}
|
|
}
|