Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/APIClient.swift
T
weishu 3510a0f4f1 fix(ios): Linux compile + full package test round — fixtures green locally (A-CI-local)
ios/scripts/linux-test.sh stages HapiKit + shared/fixtures at repo depth
into a persistent tmp dir and runs swift test in swift:6.1-noble; the
manifest drops HapiUI (SwiftUI/swift-markdown/Highlightr) under
#if os(Linux). 458 tests green, including all 48 chat and 11 pagination
golden fixtures.

Fixes that fell out of the first real compile of the blind-written port:
- ChatTypes.CodexReview.wireValue + SummaryPatching legacy id fallback:
  split expressions that exceeded the Swift 6 type-checker budget
- FileEndpointsTests: raw string containing "# terminated the literal
  early (never compiled anywhere) — now ##-delimited
- SSEClient.backoffSleep: Task { try? ... } inferred Task<()?, Never>
- Darwin gates: Security/Keychain behind canImport(Security) (tests use
  InMemoryCredentialStore via CredentialStoring), CryptoKit digest with
  FNV-1a filename fallback, FoundationNetworking imports for URLSession
  types, corelibs URLCache diskPath: initializer, get-only
  waitsForConnectivity, delegate-based SSE transport where
  URLSession.bytes(for:) does not exist
2026-08-18 11:51:57 +08:00

225 lines
7.8 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking // URLSession types live here on Linux
#endif
import HapiProtocol
/// HTTP method of a hub request.
public enum HTTPMethod: String, Sendable {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}
/// A non-2xx hub response.
///
/// Error bodies are JSON `{error, code?}` (`docs/api/client-contract/errors.md`).
/// `code` carries the stable machine discriminator when present; per the web
/// reference (`parseErrorCode`), a string `error` is used as a pseudo-code
/// fallback — acceptable for logging, not for logic. `body` keeps the raw
/// text for detail views.
public struct APIError: Error, Equatable, Sendable {
public let status: Int
public let code: String?
public let body: String?
public init(status: Int, code: String? = nil, body: String? = nil) {
self.status = status
self.code = code
self.body = body
}
/// Parses a `{error, code?}` body. Non-JSON bodies yield `code == nil`.
public init(status: Int, responseBody: Data) {
self.status = status
self.body = responseBody.isEmpty ? nil : String(data: responseBody, encoding: .utf8)
var parsedCode: String?
if let value = try? HapiJSON.decoder.decode(JSONValue.self, from: responseBody),
case .object(let object) = value {
if case .string(let code)? = object["code"] {
parsedCode = code
} else if case .string(let error)? = object["error"] {
parsedCode = error
}
}
self.code = parsedCode
}
}
extension APIError: LocalizedError {
public var errorDescription: String? {
var description = "HTTP \(status)"
if let code {
description += " (\(code))"
}
return description
}
}
/// Typed REST client for one hub.
///
/// Stateless by design: the JWT lives in ``AuthManager`` (which single-flights
/// refreshes), so this class is a `Sendable` bundle of immutable wiring and
/// requests may run concurrently. Endpoint methods live in extensions under
/// `Endpoints/`; feature packages (git/files, scratchlist, voice, usage)
/// add theirs the same way.
///
/// Every authenticated request sends `Authorization: Bearer <JWT>`; on a 401
/// it asks the auth manager for a refreshed token and retries exactly once —
/// a second 401 marks authentication failed (re-pair needed) and surfaces an
/// ``APIError``.
public final class APIClient: Sendable {
/// Origin of the active hub (from ``HubRegistry``).
public let baseURL: URL
public let authManager: AuthManager
private let performer: any HTTPPerforming
public init(
baseURL: URL,
authManager: AuthManager,
performer: any HTTPPerforming = URLSessionHTTPPerformer.shared
) {
self.baseURL = baseURL
self.authManager = authManager
self.performer = performer
}
// MARK: - Typed request surface
/// GET-style JSON request without a body.
public func request<T: Decodable>(
_ method: HTTPMethod,
_ path: String,
query: [URLQueryItem] = [],
authenticated: Bool = true
) async throws -> T {
let (data, _) = try await send(method, path: path, query: query, authenticated: authenticated)
return try HapiJSON.decoder.decode(T.self, from: data)
}
/// JSON request with a JSON body.
public func request<T: Decodable, Body: Encodable>(
_ method: HTTPMethod,
_ path: String,
query: [URLQueryItem] = [],
body: Body,
authenticated: Bool = true
) async throws -> T {
let bodyData = try HapiJSON.encoder.encode(body)
let (data, _) = try await send(
method,
path: path,
query: query,
bodyData: bodyData,
authenticated: authenticated
)
return try HapiJSON.decoder.decode(T.self, from: data)
}
/// JSON request with a pre-encoded body (multipart, etc.).
public func request<T: Decodable>(
_ method: HTTPMethod,
_ path: String,
query: [URLQueryItem] = [],
rawBody: Data,
contentType: String
) async throws -> T {
let (data, _) = try await send(
method,
path: path,
query: query,
bodyData: rawBody,
contentType: contentType
)
return try HapiJSON.decoder.decode(T.self, from: data)
}
/// Request whose response body is ignored (`{ok: true}` endpoints).
public func requestVoid(
_ method: HTTPMethod,
_ path: String,
query: [URLQueryItem] = []
) async throws {
_ = try await send(method, path: path, query: query)
}
/// Body-carrying request whose response body is ignored.
public func requestVoid<Body: Encodable>(
_ method: HTTPMethod,
_ path: String,
query: [URLQueryItem] = [],
body: Body
) async throws {
let bodyData = try HapiJSON.encoder.encode(body)
_ = try await send(method, path: path, query: query, bodyData: bodyData)
}
/// Raw-bytes GET (generated images, scratchlist attachments). Responses
/// flow through the session's `URLCache`, so the hub's
/// `ETag`/`immutable` headers make repeats free.
public func requestBytes(
_ path: String,
query: [URLQueryItem] = []
) async throws -> (data: Data, response: HTTPURLResponse) {
try await send(.get, path: path, query: query)
}
// MARK: - Core
/// One authenticated exchange with the 401 → refresh → retry-once rule
/// (`docs/api/client-contract/auth.md`). Throws ``APIError`` for non-2xx
/// statuses and propagates ``AuthError`` when re-auth is impossible.
func send(
_ method: HTTPMethod,
path: String,
query: [URLQueryItem] = [],
bodyData: Data? = nil,
contentType: String? = "application/json",
authenticated: Bool = true
) async throws -> (Data, HTTPURLResponse) {
let url = try HubRequestURL.make(baseURL: baseURL, path: path, query: query)
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
if let bodyData {
request.httpBody = bodyData
if let contentType {
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
}
}
guard authenticated else {
let (data, response) = try await performer.perform(request)
try Self.validate(status: response.statusCode, data: data)
return (data, response)
}
let token = try await authManager.validToken()
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
var (data, response) = try await performer.perform(request)
if response.statusCode == 401 {
// Routine JWT expiry: silently re-exchange the stored access
// token and retry exactly once.
let refreshed = try await authManager.refreshAfterUnauthorized(failedToken: token)
request.setValue("Bearer \(refreshed)", forHTTPHeaderField: "Authorization")
(data, response) = try await performer.perform(request)
if response.statusCode == 401 {
// A fresh JWT was refused — the credentials are dead.
await authManager.markAuthenticationFailed()
throw APIError(status: 401, responseBody: data)
}
}
try Self.validate(status: response.statusCode, data: data)
return (data, response)
}
private static func validate(status: Int, data: Data) throws {
guard (200..<300).contains(status) else {
throw APIError(status: status, responseBody: data)
}
}
}