Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Endpoints/HubEndpoints.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

48 lines
1.8 KiB
Swift

import Foundation
import HapiProtocol
/// Hub-level endpoints: health, auth exchange, visibility.
///
/// Pattern for feature packages adding endpoints later (git/files,
/// scratchlist, voice, usage): one `extension APIClient` per feature,
/// paths composed with `encodePathComponent`, bodies as HapiProtocol
/// request models (or private single-field structs), calls through
/// `request`/`requestVoid`/`requestBytes`.
extension APIClient {
/// `GET /health` (unauthenticated). Capability flags are additive.
public func health() async throws -> HubHealthResponse {
try await request(.get, "/health", authenticated: false)
}
/// `POST /api/auth` (unauthenticated) — exchanges a pairing access token
/// for a JWT. The pairing flow persists the credentials afterwards;
/// routine refreshes go through ``AuthManager`` instead.
public func authenticate(accessToken: String) async throws -> AuthResponse {
struct AuthRequestBody: Encodable {
let accessToken: String
}
return try await request(
.post,
"/api/auth",
body: AuthRequestBody(accessToken: accessToken),
authenticated: false
)
}
/// `POST /api/visibility` — reports foreground/background transitions so
/// the hub can suppress redundant pushes while the app is visible.
/// `subscriptionId` comes from the SSE `connection-changed` handshake;
/// a stale one answers 404.
public func setVisibility(subscriptionId: String, visibility: VisibilityState) async throws {
try await requestVoid(
.post,
"/api/visibility",
body: VisibilityRequest(subscriptionId: subscriptionId, visibility: visibility)
)
}
}
/// Empty JSON object body (`{}`) for endpoints whose contract expects one.
struct EmptyRequestBody: Encodable {
}