mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
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.
104 lines
3.8 KiB
Swift
104 lines
3.8 KiB
Swift
import Foundation
|
|
import HapiProtocol
|
|
|
|
extension MessagesQuery {
|
|
/// Query items in the reference client's insertion order
|
|
/// (`web/src/api/client.ts` `getMessages`), so request URLs are
|
|
/// byte-comparable across clients.
|
|
var queryItems: [URLQueryItem] {
|
|
var items: [URLQueryItem] = []
|
|
func append(_ name: String, _ value: Int?) {
|
|
if let value {
|
|
items.append(URLQueryItem(name: name, value: String(value)))
|
|
}
|
|
}
|
|
append("beforeAt", beforeAt)
|
|
append("beforeSeq", beforeSeq)
|
|
append("afterAt", afterAt)
|
|
append("afterSeq", afterSeq)
|
|
append("untilAt", untilAt)
|
|
append("untilSeq", untilSeq)
|
|
append("epoch", epoch)
|
|
append("limit", limit)
|
|
return items
|
|
}
|
|
}
|
|
|
|
/// Message paging, sending, and queue operations
|
|
/// (`docs/api/client-contract/rest.md`, `pagination.md`).
|
|
extension APIClient {
|
|
/// `GET /api/sessions/:id/messages` with a compound cursor. When the
|
|
/// page comes back with `reset: true`, discard the local window — the
|
|
/// requested epoch no longer exists.
|
|
public func messages(sessionId: String, query: MessagesQuery = .latest()) async throws -> MessagesResponse {
|
|
try await request(
|
|
.get,
|
|
"/api/sessions/\(encodePathComponent(sessionId))/messages",
|
|
query: query.queryItems
|
|
)
|
|
}
|
|
|
|
/// `POST /api/sessions/:id/messages`. Answers `{ok: true}` only — the
|
|
/// stored message arrives via SSE (`message-received`), reconciled with
|
|
/// the optimistic row through `localId`.
|
|
public func sendMessage(sessionId: String, _ message: SendMessageRequest) async throws {
|
|
try await requestVoid(
|
|
.post,
|
|
"/api/sessions/\(encodePathComponent(sessionId))/messages",
|
|
body: message
|
|
)
|
|
}
|
|
|
|
/// Convenience overload of ``sendMessage(sessionId:_:)``.
|
|
public func sendMessage(
|
|
sessionId: String,
|
|
text: String,
|
|
localId: String? = nil,
|
|
attachments: [AttachmentMetadata]? = nil,
|
|
scheduledAt: Int? = nil,
|
|
deliveryMode: MessageDeliveryMode? = nil
|
|
) async throws {
|
|
try await sendMessage(
|
|
sessionId: sessionId,
|
|
SendMessageRequest(
|
|
text: text,
|
|
localId: localId,
|
|
attachments: attachments,
|
|
scheduledAt: scheduledAt,
|
|
deliveryMode: deliveryMode
|
|
)
|
|
)
|
|
}
|
|
|
|
/// `DELETE /api/sessions/:id/messages/:messageId` — cancel a queued
|
|
/// message. `.invoked` means the cancel came too late.
|
|
public func cancelMessage(sessionId: String, messageId: String) async throws -> CancelMessageResponse {
|
|
try await request(
|
|
.delete,
|
|
"/api/sessions/\(encodePathComponent(sessionId))/messages/\(encodePathComponent(messageId))"
|
|
)
|
|
}
|
|
|
|
/// `POST /api/sessions/:id/messages/:messageId/steer` — promote a queued
|
|
/// message to steer delivery.
|
|
public func steerMessage(sessionId: String, messageId: String) async throws -> SteerQueuedMessageResponse {
|
|
try await request(
|
|
.post,
|
|
"/api/sessions/\(encodePathComponent(sessionId))/messages/\(encodePathComponent(messageId))/steer"
|
|
)
|
|
}
|
|
|
|
/// `POST /api/sessions/:id/messages/queued-state` — resync optimistic
|
|
/// sends after a reconnect (≤ 1000 localIds).
|
|
public func queuedState(sessionId: String, localIds: [String]) async throws -> QueuedStateResponse {
|
|
struct QueuedStateRequest: Encodable {
|
|
let localIds: [String]
|
|
}
|
|
return try await request(
|
|
.post,
|
|
"/api/sessions/\(encodePathComponent(sessionId))/messages/queued-state",
|
|
body: QueuedStateRequest(localIds: localIds)
|
|
)
|
|
}
|
|
}
|