Files
hapi/ios/Packages/HapiKit/Sources/HapiProtocol/Models/ApiResponses.swift
T
weishu 1f0ec6184d feat(ios): HapiClient API transport + auth (A-M1b)
APIClient (final class, Sendable) with typed endpoints for the M2/M3 REST
surface behind an HTTPPerforming seam; actor AuthManager with single-flight
JWT refresh (POST /api/auth), proactive refresh 10 min before exp, terminal
authFailed state, and 401 -> refresh -> retry-once wiring per
docs/api/client-contract/auth.md; Keychain credential store (per-hub records
under run.hapi.companion) with CredentialStoring seam + in-memory double;
HubRegistry (ordered hubs + active hub in injectable UserDefaults, origin
normalization); payload-only JWT decoding; APIError {status, code, body}
parsing per errors.md; minimal multipart builder for M4c dictation; 256 MB
URLCache session for generated images.

Request/response wire models (SendMessageRequest, PermissionApproveRequest,
SpawnRequest/SpawnResponse, MessagesQuery, envelopes, RPC-wrapped shapes)
join HapiProtocol/Models mirroring shared/src/apiTypes.ts.

swift-testing coverage: JWT decode (padding/garbage/hostile exp), auth
single-flight (8 concurrent callers -> one exchange), 401 retry-once and
terminal paths, APIError body parsing, endpoint request construction
byte-checks (cursor queries, deliveryMode body, answers formats, explicit
null model reset) via a recording performer, hub URL normalization,
multipart bytes.
2026-08-17 15:20:03 +08:00

416 lines
13 KiB
Swift

import Foundation
// Response shapes for the REST surface (`docs/api/client-contract/rest.md`).
// Field names mirror `shared/src/apiTypes.ts` / `shared/src/schemas.ts`
// one-to-one — no CodingKeys anywhere in this file. Discriminated unions
// (`SpawnResponse`, `CancelMessageResponse`, `SteerQueuedMessageResponse`)
// are decode-only: the client never sends them.
// MARK: - Auth & health
/// The `user` object of the auth response.
public struct AuthUser: Codable, Equatable, Sendable {
public var id: Int
public var username: String?
public var firstName: String?
public var lastName: String?
public init(id: Int, username: String? = nil, firstName: String? = nil, lastName: String? = nil) {
self.id = id
self.username = username
self.firstName = firstName
self.lastName = lastName
}
}
/// Success body of `POST /api/auth`.
public struct AuthResponse: Codable, Equatable, Sendable {
/// The JWT (HS256, `{uid, ns}`, 4 h expiry).
public var token: String
public var user: AuthUser
public init(token: String, user: AuthUser) {
self.token = token
self.user = user
}
}
/// Additive capability flags of `GET /health`. Unknown keys are ignored.
public struct HubCapabilities: Codable, Equatable, Sendable {
public var workGraph: Bool?
public var titleSuggestion: Bool?
public init(workGraph: Bool? = nil, titleSuggestion: Bool? = nil) {
self.workGraph = workGraph
self.titleSuggestion = titleSuggestion
}
}
/// Body of `GET /health` (unauthenticated).
public struct HubHealthResponse: Codable, Equatable, Sendable {
public var status: String
public var protocolVersion: Int
public var capabilities: HubCapabilities?
public init(status: String, protocolVersion: Int, capabilities: HubCapabilities? = nil) {
self.status = status
self.protocolVersion = protocolVersion
self.capabilities = capabilities
}
}
// MARK: - Envelopes
/// Envelope of `GET /api/sessions`.
public struct SessionsResponse: Codable, Equatable, Sendable {
public var sessions: [SessionSummary]
public init(sessions: [SessionSummary]) {
self.sessions = sessions
}
}
/// Envelope of `GET /api/sessions/:id`.
public struct SessionResponse: Codable, Equatable, Sendable {
public var session: Session
public init(session: Session) {
self.session = session
}
}
/// Envelope of `GET /api/machines`.
public struct MachinesResponse: Codable, Equatable, Sendable {
public var machines: [Machine]
public init(machines: [Machine]) {
self.machines = machines
}
}
// MARK: - Session lifecycle
/// Body of `POST /api/sessions/:id/resume` — `{type: 'success', sessionId}`.
/// Only `sessionId` is modeled; failures arrive as HTTP errors.
///
/// The returned id may differ from the one the call was made on (fresh spawn
/// under a new id) — callers must follow it.
public struct ResumeSessionResponse: Codable, Equatable, Sendable {
public var sessionId: String
public init(sessionId: String) {
self.sessionId = sessionId
}
}
/// Body of `POST /api/sessions/:id/reopen`.
///
/// Mirrors `ReopenSessionResponseSchema` (`shared/src/apiTypes.ts`). As with
/// resume, `sessionId` may differ from the id the call was made on.
public struct ReopenSessionResponse: Codable, Equatable, Sendable {
public var ok: Bool
public var sessionId: String
public var resumed: Bool
/// `'acp' | 'stream-json'`; kept open for forward compatibility.
public var cursorSessionProtocol: String?
public init(ok: Bool, sessionId: String, resumed: Bool, cursorSessionProtocol: String? = nil) {
self.ok = ok
self.sessionId = sessionId
self.resumed = resumed
self.cursorSessionProtocol = cursorSessionProtocol
}
}
// MARK: - Spawning
/// Body of `POST /api/machines/:id/spawn`, discriminated on `type` — a failed
/// spawn is still HTTP 200 with `type: 'error'`.
public enum SpawnResponse: Equatable, Sendable {
case success(sessionId: String)
case error(message: String)
}
extension SpawnResponse: Decodable {
private enum CodingKeys: String, CodingKey {
case type
case sessionId
case message
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "success":
self = .success(sessionId: try container.decode(String.self, forKey: .sessionId))
case "error":
self = .error(message: try container.decode(String.self, forKey: .message))
default:
throw DecodingError.dataCorrupted(DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown SpawnResponse type '\(type)'"
))
}
}
}
// MARK: - Message queue operations
/// Body of `DELETE /api/sessions/:id/messages/:messageId`, discriminated on
/// `status`. `invoked` means the cancel arrived too late — the message was
/// already handed to the agent.
public enum CancelMessageResponse: Equatable, Sendable {
case cancelled(localId: String?)
case invoked(message: DecryptedMessage)
}
extension CancelMessageResponse: Decodable {
private enum CodingKeys: String, CodingKey {
case status
case localId
case message
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let status = try container.decode(String.self, forKey: .status)
switch status {
case "cancelled":
self = .cancelled(localId: try container.decodeIfPresent(String.self, forKey: .localId))
case "invoked":
self = .invoked(message: try container.decode(DecryptedMessage.self, forKey: .message))
default:
throw DecodingError.dataCorrupted(DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown CancelMessageResponse status '\(status)'"
))
}
}
}
/// Body of `POST /api/sessions/:id/messages/:messageId/steer`, discriminated
/// on `status`.
public enum SteerQueuedMessageResponse: Equatable, Sendable {
case steered(localId: String)
case invoked(message: DecryptedMessage)
case failed(error: String, localId: String?)
}
extension SteerQueuedMessageResponse: Decodable {
private enum CodingKeys: String, CodingKey {
case status
case localId
case message
case error
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let status = try container.decode(String.self, forKey: .status)
switch status {
case "steered":
self = .steered(localId: try container.decode(String.self, forKey: .localId))
case "invoked":
self = .invoked(message: try container.decode(DecryptedMessage.self, forKey: .message))
case "failed":
self = .failed(
error: try container.decode(String.self, forKey: .error),
localId: try container.decodeIfPresent(String.self, forKey: .localId)
)
default:
throw DecodingError.dataCorrupted(DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown SteerQueuedMessageResponse status '\(status)'"
))
}
}
}
/// One invoked entry of the queued-state response.
public struct InvokedLocalMessage: Codable, Equatable, Sendable {
public var localId: String
public var invokedAt: Int
public init(localId: String, invokedAt: Int) {
self.localId = localId
self.invokedAt = invokedAt
}
}
/// Body of `POST /api/sessions/:id/messages/queued-state` — resyncs
/// optimistic sends after a reconnect.
public struct QueuedStateResponse: Codable, Equatable, Sendable {
public var queuedLocalIds: [String]
public var invokedLocalMessages: [InvokedLocalMessage]
public init(queuedLocalIds: [String], invokedLocalMessages: [InvokedLocalMessage]) {
self.queuedLocalIds = queuedLocalIds
self.invokedLocalMessages = invokedLocalMessages
}
}
// MARK: - Session catalogs (RPC-wrapped)
/// One slash command of the session catalog. `source` is kept as a raw string
/// (`'builtin' | 'user' | 'plugin' | 'project'` today) so a new source never
/// breaks decoding.
public struct SlashCommand: Codable, Equatable, Sendable {
public var name: String
public var description: String?
public var source: String
public var content: String?
public var pluginName: String?
public init(
name: String,
description: String? = nil,
source: String,
content: String? = nil,
pluginName: String? = nil
) {
self.name = name
self.description = description
self.source = source
self.content = content
self.pluginName = pluginName
}
}
/// Body of `GET /api/sessions/:id/slash-commands` (RPC envelope — check
/// `success`, HTTP 200 alone means nothing).
public struct SlashCommandsResponse: Codable, Equatable, Sendable {
public var success: Bool
public var commands: [SlashCommand]?
public var error: String?
public init(success: Bool, commands: [SlashCommand]? = nil, error: String? = nil) {
self.success = success
self.commands = commands
self.error = error
}
}
/// One skill of the session catalog.
public struct SkillSummary: Codable, Equatable, Sendable {
public var name: String
public var description: String?
public init(name: String, description: String? = nil) {
self.name = name
self.description = description
}
}
/// Body of `GET /api/sessions/:id/skills` (RPC envelope).
public struct SkillsResponse: Codable, Equatable, Sendable {
public var success: Bool
public var skills: [SkillSummary]?
public var error: String?
public init(success: Bool, skills: [SkillSummary]? = nil, error: String? = nil) {
self.success = success
self.skills = skills
self.error = error
}
}
// MARK: - Machine directory browsing
/// Entry type of a directory listing.
public enum DirectoryEntryType: String, Codable, Sendable {
case file
case directory
case other
}
/// One entry of `GET /api/sessions/:id/directory`.
public struct DirectoryEntry: Codable, Equatable, Sendable {
public var name: String
public var type: DirectoryEntryType
/// Size in bytes (files only).
public var size: Int?
/// mtime, epoch ms.
public var modified: Int?
public init(name: String, type: DirectoryEntryType, size: Int? = nil, modified: Int? = nil) {
self.name = name
self.type = type
self.size = size
self.modified = modified
}
}
/// One entry of `POST /api/machines/:id/list-directory`
/// (`DirectoryEntry & {isGitRepo?}` flattened).
public struct MachineDirectoryEntry: Codable, Equatable, Sendable {
public var name: String
public var type: DirectoryEntryType
public var size: Int?
public var modified: Int?
public var isGitRepo: Bool?
public init(
name: String,
type: DirectoryEntryType,
size: Int? = nil,
modified: Int? = nil,
isGitRepo: Bool? = nil
) {
self.name = name
self.type = type
self.size = size
self.modified = modified
self.isGitRepo = isGitRepo
}
}
/// Body of `POST /api/machines/:id/list-directory` (RPC envelope).
public struct MachineListDirectoryResponse: Codable, Equatable, Sendable {
public var success: Bool
public var entries: [MachineDirectoryEntry]?
public var error: String?
public init(success: Bool, entries: [MachineDirectoryEntry]? = nil, error: String? = nil) {
self.success = success
self.entries = entries
self.error = error
}
}
/// Body of `POST /api/machines/:id/paths/exists`.
public struct MachinePathsExistsResponse: Codable, Equatable, Sendable {
public var exists: [String: Bool]
public init(exists: [String: Bool]) {
self.exists = exists
}
}
// MARK: - Uploads
/// Body of `POST /api/sessions/:id/upload` (RPC envelope). `path` feeds the
/// `attachments` metadata of a subsequent send-message.
public struct UploadFileResponse: Codable, Equatable, Sendable {
public var success: Bool
public var path: String?
public var error: String?
public init(success: Bool, path: String? = nil, error: String? = nil) {
self.success = success
self.path = path
self.error = error
}
}
/// Body of `POST /api/sessions/:id/upload/delete` (RPC envelope).
public struct DeleteUploadResponse: Codable, Equatable, Sendable {
public var success: Bool
public var error: String?
public init(success: Bool, error: String? = nil) {
self.success = success
self.error = error
}
}