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

56 lines
2.1 KiB
Swift

import Foundation
import HapiProtocol
/// Generated images and message-attachment uploads
/// (`docs/api/client-contract/rest.md`).
extension APIClient {
/// `GET /api/sessions/:id/generated-images/:imageId` — raw bytes.
///
/// The image id is a content fingerprint doubling as the `ETag`, and the
/// hub sends `Cache-Control: private, max-age=31536000, immutable`, so
/// the configured `URLCache` (see ``URLSessionHTTPPerformer``) serves
/// repeats locally and revalidates with `If-None-Match` transparently.
public func generatedImage(
sessionId: String,
imageId: String
) async throws -> (data: Data, mimeType: String?) {
let (data, response) = try await requestBytes(
"/api/sessions/\(encodePathComponent(sessionId))/generated-images/\(encodePathComponent(imageId))"
)
return (data, response.value(forHTTPHeaderField: "Content-Type"))
}
/// `POST /api/sessions/:id/upload` — JSON + base64, **not** multipart.
/// Decoded size limit 50 MB (413 above). Requires an active session.
/// Pass the returned `path` in the `attachments` metadata of a
/// subsequent send-message.
public func uploadFile(
sessionId: String,
filename: String,
data: Data,
mimeType: String
) async throws -> UploadFileResponse {
try await request(
.post,
"/api/sessions/\(encodePathComponent(sessionId))/upload",
body: UploadFileRequest(
filename: filename,
content: data.base64EncodedString(),
mimeType: mimeType
)
)
}
/// `POST /api/sessions/:id/upload/delete` — removes a pending upload.
public func deleteUpload(sessionId: String, path: String) async throws -> DeleteUploadResponse {
struct DeleteUploadRequest: Encodable {
let path: String
}
return try await request(
.post,
"/api/sessions/\(encodePathComponent(sessionId))/upload/delete",
body: DeleteUploadRequest(path: path)
)
}
}