sync app filled in
This commit is contained in:
263
apps/macos-sync/Sources/CairnquireSync/API/Client.swift
Normal file
263
apps/macos-sync/Sources/CairnquireSync/API/Client.swift
Normal file
@@ -0,0 +1,263 @@
|
||||
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<T: Decodable>(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<T: Decodable>(_ request: URLRequest) async throws -> T {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
return try decodeResponse(data: data, response: response)
|
||||
}
|
||||
|
||||
private func performUpload<T: Decodable>(
|
||||
_ 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)
|
||||
}
|
||||
|
||||
// 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 {}
|
||||
Reference in New Issue
Block a user