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

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")
}
}