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

69 lines
2.9 KiB
Swift

import Foundation
/// Seam between the client stack and the actual HTTP transport.
///
/// `APIClient` and `AuthManager` speak only to this protocol, so tests inject
/// a recording/scripted performer instead of resorting to `URLProtocol`
/// tricks. The production implementation wraps `URLSession`.
public protocol HTTPPerforming: Sendable {
/// Executes one HTTP exchange. Implementations must not throw on non-2xx
/// statuses — status handling (401 refresh, `APIError`) belongs to the
/// caller. Throw only for transport-level failures (`URLError`).
func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
/// Production `HTTPPerforming` backed by `URLSession`.
public struct URLSessionHTTPPerformer: HTTPPerforming {
public let session: URLSession
public init(session: URLSession) {
self.session = session
}
/// Shared instance used by default across the client stack, so every
/// consumer benefits from one connection pool and one URL cache.
public static let shared = URLSessionHTTPPerformer(session: makeDefaultSession())
/// `URLSession` configured for the hub protocol.
///
/// The 256 MB disk cache exists chiefly for generated images: the hub
/// serves them with `ETag` + `Cache-Control: private, max-age=31536000,
/// immutable`, so `URLCache` answers repeats locally (and revalidates via
/// `If-None-Match`/304 without the hub's CLI round-trip). Streaming gzip
/// decompression of JSON responses is transparent in `URLSession`.
public static func makeDefaultSession() -> URLSession {
let configuration = URLSessionConfiguration.default
configuration.urlCache = URLCache(
memoryCapacity: 32 * 1024 * 1024,
diskCapacity: 256 * 1024 * 1024,
directory: nil
)
configuration.requestCachePolicy = .useProtocolCachePolicy
return URLSession(configuration: configuration)
}
public func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
return (data, http)
}
}
/// JSON coders shared by the whole transport layer so wire behavior is
/// uniform (and request bodies are byte-stable for tests).
public enum HapiJSON {
/// Wire fields are camelCase and dates are epoch-ms integers, so the
/// default strategies are exactly right.
public static let decoder = JSONDecoder()
/// `sortedKeys` makes encoded bodies deterministic;
/// `withoutEscapingSlashes` keeps embedded paths readable.
public static let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
return encoder
}()
}