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

53 lines
2.2 KiB
Swift

import Foundation
/// URL assembly shared by `APIClient` and `AuthManager`.
///
/// Percent-encoding is done by hand with the RFC 3986 unreserved set (the
/// strictest choice) so that path components and query values match what the
/// web client produces via `encodeURIComponent`/`URLSearchParams` — notably
/// `+` is encoded as `%2B`, which `URLComponents.queryItems` would leave
/// literal (and the hub would decode as a space).
enum HubRequestURL {
/// RFC 3986 unreserved characters — everything else is percent-encoded.
static let unreserved = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
)
/// Builds `origin(baseURL) + path + query`. `path` must be an absolute,
/// already-percent-encoded path (compose dynamic segments with
/// ``encodePathComponent(_:)``); any path on `baseURL` itself is replaced,
/// matching the web client's `new URL(path, baseUrl)` for absolute paths.
static func make(baseURL: URL, path: String, query: [URLQueryItem]) throws -> URL {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else {
throw URLError(.badURL)
}
components.percentEncodedPath = path
components.fragment = nil
components.percentEncodedQuery = query.isEmpty ? nil : encodeQuery(query)
guard let url = components.url else {
throw URLError(.badURL)
}
return url
}
/// Joins items in the given order as `name=value`, both strictly encoded.
static func encodeQuery(_ items: [URLQueryItem]) -> String {
items.map { item in
let name = encodeComponent(item.name)
guard let value = item.value else { return name }
return "\(name)=\(encodeComponent(value))"
}
.joined(separator: "&")
}
static func encodeComponent(_ value: String) -> String {
value.addingPercentEncoding(withAllowedCharacters: unreserved) ?? value
}
}
/// Percent-encodes one path segment (the `encodeURIComponent` equivalent used
/// for `:id`-style params throughout the endpoint layer).
func encodePathComponent(_ value: String) -> String {
HubRequestURL.encodeComponent(value)
}