import Foundation public typealias UploadProgressHandler = @Sendable (Double) -> Void private final class UploadProgressTaskDelegate: NSObject, URLSessionTaskDelegate { private let onProgress: UploadProgressHandler init(onProgress: @escaping UploadProgressHandler) { self.onProgress = onProgress } func urlSession( _ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64 ) { guard totalBytesExpectedToSend > 0 else { return } let progress = min(max(Double(totalBytesSent) / Double(totalBytesExpectedToSend), 0), 1) onProgress(progress) } } public actor APIClient { public let baseURL: URL public var apiToken: String? private let session: URLSession private let decoder: JSONDecoder private let encoder: JSONEncoder public init(baseURL: URL, apiToken: String? = nil) { self.baseURL = baseURL self.apiToken = apiToken if self.apiToken == nil && Self.isLocalhost(baseURL) { self.apiToken = "dev" } self.session = URLSession(configuration: .default) self.decoder = JSONDecoder() self.decoder.dateDecodingStrategy = .iso8601 self.encoder = JSONEncoder() self.encoder.dateEncodingStrategy = .iso8601 } private static func isLocalhost(_ url: URL) -> Bool { guard let host = url.host else { return false } return host == "localhost" || host == "127.0.0.1" } public func setAPIToken(_ token: String?) { self.apiToken = token } private func request(for path: String) -> URLRequest { var request = URLRequest(url: baseURL.appendingPathComponent(path)) if let token = apiToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } request.setValue("application/json", forHTTPHeaderField: "Accept") return request } private func decodeResponse(data: Data, response: URLResponse) throws -> T { guard let httpResponse = response as? HTTPURLResponse else { throw APIError(error: "invalid response") } if httpResponse.statusCode >= 400 { if let error = try? decoder.decode(APIError.self, from: data) { throw error } throw APIError(error: "HTTP \(httpResponse.statusCode)") } if T.self == EmptyResponse.self { return EmptyResponse() as! T } return try decoder.decode(T.self, from: data) } private func perform(_ request: URLRequest) async throws -> T { let (data, response) = try await session.data(for: request) return try decodeResponse(data: data, response: response) } private func performUpload( _ request: URLRequest, body: Data, onProgress: UploadProgressHandler? ) async throws -> T { onProgress?(0) let delegate = onProgress.map { UploadProgressTaskDelegate(onProgress: $0) } let (data, response) = try await session.upload(for: request, from: body, delegate: delegate) onProgress?(1) return try decodeResponse(data: data, response: response) } // MARK: - Uploads public func uploadFile( url: URL, folder: String? = nil, duplicateAction: UploadDuplicateAction? = nil, onProgress: UploadProgressHandler? = nil ) async throws -> UploadResponse { guard let token = apiToken else { throw APIError(error: "no API token configured") } let boundary = UUID().uuidString var request = URLRequest(url: baseURL.appendingPathComponent("api/uploads")) request.httpMethod = "POST" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") let data = try Data(contentsOf: url) let filename = url.lastPathComponent var body = Data() body.append("--\(boundary)\r\n".data(using: .utf8)!) if let folder, !folder.isEmpty { body.append("Content-Disposition: form-data; name=\"folder\"\r\n\r\n".data(using: .utf8)!) body.append(folder.data(using: .utf8)!) body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!) } if let duplicateAction { body.append("Content-Disposition: form-data; name=\"duplicateAction\"\r\n\r\n".data(using: .utf8)!) body.append(duplicateAction.rawValue.data(using: .utf8)!) body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!) } body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!) body.append(data) body.append("\r\n".data(using: .utf8)!) body.append("--\(boundary)--\r\n".data(using: .utf8)!) return try await performUpload(request, body: body, onProgress: onProgress) } public func uploadData( _ data: Data, filename: String, folder: String? = nil, duplicateAction: UploadDuplicateAction? = nil, onProgress: UploadProgressHandler? = nil ) async throws -> UploadResponse { guard let token = apiToken else { throw APIError(error: "no API token configured") } let boundary = UUID().uuidString var request = URLRequest(url: baseURL.appendingPathComponent("api/uploads")) request.httpMethod = "POST" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") var body = Data() body.append("--\(boundary)\r\n".data(using: .utf8)!) if let folder, !folder.isEmpty { body.append("Content-Disposition: form-data; name=\"folder\"\r\n\r\n".data(using: .utf8)!) body.append(folder.data(using: .utf8)!) body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!) } if let duplicateAction { body.append("Content-Disposition: form-data; name=\"duplicateAction\"\r\n\r\n".data(using: .utf8)!) body.append(duplicateAction.rawValue.data(using: .utf8)!) body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!) } body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!) body.append(data) body.append("\r\n".data(using: .utf8)!) body.append("--\(boundary)--\r\n".data(using: .utf8)!) return try await performUpload(request, body: body, onProgress: onProgress) } // MARK: - Attachments public func listAttachments() async throws -> [AttachmentRecord] { let request = self.request(for: "api/attachments") let response: AttachmentsListResponse = try await perform(request) return response.attachments } public func attachmentURL(hash: String) -> URL { baseURL.appendingPathComponent("attachments/\(hash)") } // MARK: - Documents public func listDocuments() async throws -> [DocumentRecord] { let request = self.request(for: "api/documents") let response: DocumentsListResponse = try await perform(request) return response.documents } public func saveDocument(path: String, content: String, baseHash: String? = nil) async throws -> DocumentSaveResponse { var request = self.request(for: "api/documents/\(path)") request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body = DocumentSaveRequest(content: content, baseHash: baseHash) request.httpBody = try encoder.encode(body) return try await perform(request) } public func searchDocuments(query: String) async throws -> [SearchResult] { var components = URLComponents(url: baseURL.appendingPathComponent("api/search"), resolvingAgainstBaseURL: false) components?.queryItems = [URLQueryItem(name: "q", value: query)] guard let url = components?.url else { throw APIError(error: "invalid search URL") } var request = URLRequest(url: url) if let token = apiToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } request.setValue("application/json", forHTTPHeaderField: "Accept") let response: SearchResponse = try await perform(request) return response.results } // MARK: - Sync public func syncInit(deviceId: String, rootPath: String) async throws -> SyncInitResponse { var request = self.request(for: "api/sync/init") request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body = SyncInitRequest(deviceId: deviceId, rootPath: rootPath) request.httpBody = try encoder.encode(body) return try await perform(request) } public func syncDelta(snapshotId: String, clientDelta: [Change]) async throws -> DeltaResponse { var request = self.request(for: "api/sync/delta") request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body = DeltaRequest(snapshotId: snapshotId, clientDelta: clientDelta) request.httpBody = try encoder.encode(body) return try await perform(request) } public func fetchContent(hash: String) async throws -> Data { let request = self.request(for: "api/content/\(hash)") let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw APIError(error: "invalid response") } if httpResponse.statusCode >= 400 { if let error = try? decoder.decode(APIError.self, from: data) { throw error } throw APIError(error: "HTTP \(httpResponse.statusCode)") } return data } // MARK: - Health public func healthCheck() async throws -> Bool { let request = self.request(for: "health") let (_, response) = try await session.data(for: request) return (response as? HTTPURLResponse)?.statusCode == 200 } } public struct EmptyResponse: Decodable {}