mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-11 19:50:05 +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.
128 lines
4.1 KiB
Swift
128 lines
4.1 KiB
Swift
import Foundation
|
|
import HapiClient
|
|
import HapiProtocol
|
|
import Testing
|
|
|
|
// MARK: - JWT construction
|
|
|
|
func base64URLEncode(_ data: Data) -> String {
|
|
data.base64EncodedString()
|
|
.replacingOccurrences(of: "+", with: "-")
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
.replacingOccurrences(of: "=", with: "")
|
|
}
|
|
|
|
/// A well-formed (unsigned) JWT with the hub's payload shape.
|
|
func makeJWT(uid: Int = 1, ns: String = "default", exp: Int? = nil) -> String {
|
|
var fields = ["\"uid\":\(uid)", "\"ns\":\"\(ns)\""]
|
|
if let exp {
|
|
fields.append("\"exp\":\(exp)")
|
|
}
|
|
let payload = "{\(fields.joined(separator: ","))}"
|
|
return "eyJhbGciOiJIUzI1NiJ9.\(base64URLEncode(Data(payload.utf8))).c2ln"
|
|
}
|
|
|
|
// MARK: - Error capture
|
|
|
|
/// Runs `body` and returns the error it threw, or `nil` when it succeeded.
|
|
func capturedError<T>(_ body: () async throws -> T) async -> (any Error)? {
|
|
do {
|
|
_ = try await body()
|
|
return nil
|
|
} catch {
|
|
return error
|
|
}
|
|
}
|
|
|
|
// MARK: - Scripted HTTP performer
|
|
|
|
/// Records every request and answers from a FIFO of stubbed responses
|
|
/// (falling back to a configurable default when the queue is empty).
|
|
actor RecordingPerformer: HTTPPerforming {
|
|
struct Stub: Sendable {
|
|
var status: Int = 200
|
|
var body: Data = Data("{}".utf8)
|
|
var headers: [String: String] = [:]
|
|
}
|
|
|
|
private(set) var requests: [URLRequest] = []
|
|
private var queue: [Stub] = []
|
|
private var fallback = Stub()
|
|
private var delayNanoseconds: UInt64 = 0
|
|
|
|
func enqueue(status: Int = 200, json: String = "{}", headers: [String: String] = [:]) {
|
|
queue.append(Stub(status: status, body: Data(json.utf8), headers: headers))
|
|
}
|
|
|
|
func setFallback(status: Int, json: String) {
|
|
fallback = Stub(status: status, body: Data(json.utf8))
|
|
}
|
|
|
|
/// Delay before answering — lets concurrent callers pile up on one
|
|
/// in-flight exchange.
|
|
func setDelay(nanoseconds: UInt64) {
|
|
delayNanoseconds = nanoseconds
|
|
}
|
|
|
|
func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
|
requests.append(request)
|
|
if delayNanoseconds > 0 {
|
|
try await Task.sleep(nanoseconds: delayNanoseconds)
|
|
}
|
|
let stub = queue.isEmpty ? fallback : queue.removeFirst()
|
|
guard let url = request.url,
|
|
let response = HTTPURLResponse(
|
|
url: url,
|
|
statusCode: stub.status,
|
|
httpVersion: "HTTP/1.1",
|
|
headerFields: stub.headers
|
|
) else {
|
|
throw URLError(.badURL)
|
|
}
|
|
return (stub.body, response)
|
|
}
|
|
}
|
|
|
|
// MARK: - Wiring
|
|
|
|
let testHubURLString = "https://hub.test"
|
|
let testEpochSeconds = 1_700_000_000
|
|
let testNow = Date(timeIntervalSince1970: TimeInterval(testEpochSeconds))
|
|
|
|
struct Harness {
|
|
let baseURL: URL
|
|
let performer: RecordingPerformer
|
|
let store: InMemoryCredentialStore
|
|
let auth: AuthManager
|
|
let client: APIClient
|
|
}
|
|
|
|
/// Builds the full client stack against a `RecordingPerformer`.
|
|
/// `jwt` (when given) is persisted in the credential record, which is how
|
|
/// `AuthManager` picks up an initial token.
|
|
func makeHarness(
|
|
paired: Bool = true,
|
|
accessToken: String = "access-token",
|
|
jwt: String? = nil,
|
|
now: Date = testNow
|
|
) throws -> Harness {
|
|
let baseURL = try #require(URL(string: testHubURLString))
|
|
let performer = RecordingPerformer()
|
|
let store = InMemoryCredentialStore()
|
|
if paired {
|
|
try store.store(HubCredentials(hubUrl: testHubURLString, accessToken: accessToken, jwt: jwt))
|
|
}
|
|
let auth = AuthManager(baseURL: baseURL, credentialStore: store, performer: performer, now: { now })
|
|
let client = APIClient(baseURL: baseURL, authManager: auth, performer: performer)
|
|
return Harness(baseURL: baseURL, performer: performer, store: store, auth: auth, client: client)
|
|
}
|
|
|
|
/// A JWT that will not expire during a test.
|
|
func freshJWT(ns: String = "default") -> String {
|
|
makeJWT(ns: ns, exp: testEpochSeconds + 4 * 3600)
|
|
}
|
|
|
|
func authResponseJSON(token: String) -> String {
|
|
"{\"token\":\"\(token)\",\"user\":{\"id\":1}}"
|
|
}
|