mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +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.
66 lines
2.3 KiB
Swift
66 lines
2.3 KiB
Swift
import Foundation
|
|
|
|
/// Minimal `multipart/form-data` builder for the one multipart endpoint,
|
|
/// `POST /api/voice/transcription` (M4c dictation): text fields plus one
|
|
/// file part.
|
|
///
|
|
/// The boundary is injectable so tests can compare exact bytes; production
|
|
/// callers use the random default. Append order is preserved.
|
|
public struct MultipartFormData: Sendable {
|
|
public let boundary: String
|
|
private var body = Data()
|
|
|
|
public init(boundary: String = MultipartFormData.makeBoundary()) {
|
|
self.boundary = boundary
|
|
}
|
|
|
|
public static func makeBoundary() -> String {
|
|
"hapi.boundary.\(UUID().uuidString)"
|
|
}
|
|
|
|
/// Value for the request's `Content-Type` header.
|
|
public var contentType: String {
|
|
"multipart/form-data; boundary=\(boundary)"
|
|
}
|
|
|
|
/// Appends a plain text field.
|
|
public mutating func appendField(name: String, value: String) {
|
|
appendString("--\(boundary)\r\n")
|
|
appendString("Content-Disposition: form-data; name=\"\(Self.sanitize(name))\"\r\n\r\n")
|
|
appendString(value)
|
|
appendString("\r\n")
|
|
}
|
|
|
|
/// Appends a file part.
|
|
public mutating func appendFile(fieldName: String, filename: String, mimeType: String, data: Data) {
|
|
appendString("--\(boundary)\r\n")
|
|
appendString(
|
|
"Content-Disposition: form-data; name=\"\(Self.sanitize(fieldName))\"; "
|
|
+ "filename=\"\(Self.sanitize(filename))\"\r\n"
|
|
)
|
|
appendString("Content-Type: \(mimeType)\r\n\r\n")
|
|
body.append(data)
|
|
appendString("\r\n")
|
|
}
|
|
|
|
/// The complete request body, including the closing boundary. The
|
|
/// builder stays usable (value semantics) — encoding does not consume it.
|
|
public func encodedBody() -> Data {
|
|
var encoded = body
|
|
encoded.append(Data("--\(boundary)--\r\n".utf8))
|
|
return encoded
|
|
}
|
|
|
|
private mutating func appendString(_ string: String) {
|
|
body.append(Data(string.utf8))
|
|
}
|
|
|
|
/// Header values must not smuggle CRLF or unescaped quotes.
|
|
private static func sanitize(_ value: String) -> String {
|
|
value
|
|
.replacingOccurrences(of: "\r", with: "")
|
|
.replacingOccurrences(of: "\n", with: "")
|
|
.replacingOccurrences(of: "\"", with: "%22")
|
|
}
|
|
}
|