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 {}
|
||||
185
apps/macos-sync/Sources/CairnquireSync/API/Models.swift
Normal file
185
apps/macos-sync/Sources/CairnquireSync/API/Models.swift
Normal file
@@ -0,0 +1,185 @@
|
||||
import Foundation
|
||||
|
||||
public struct AttachmentRecord: Codable, Identifiable {
|
||||
public var id: String { hash }
|
||||
public let hash: String
|
||||
public let originalName: String
|
||||
public let contentType: String
|
||||
public let sizeBytes: Int
|
||||
public let createdAt: Date
|
||||
public let url: String
|
||||
public let kind: String?
|
||||
public let documentPath: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case hash
|
||||
case originalName
|
||||
case contentType
|
||||
case sizeBytes
|
||||
case createdAt
|
||||
case url
|
||||
case kind
|
||||
case documentPath
|
||||
}
|
||||
}
|
||||
|
||||
public struct UploadResponse: Codable {
|
||||
public let hash: String
|
||||
public let contentType: String
|
||||
public let attachmentUrl: String
|
||||
public let url: String?
|
||||
public let kind: String?
|
||||
}
|
||||
|
||||
public struct DocumentRecord: Codable, Identifiable {
|
||||
public let id: String
|
||||
public let path: String
|
||||
public let title: String
|
||||
public let hash: String
|
||||
public let createdAt: Date?
|
||||
public let updatedAt: Date?
|
||||
}
|
||||
|
||||
public struct DocumentSaveRequest: Codable {
|
||||
public let content: String
|
||||
public let baseHash: String?
|
||||
}
|
||||
|
||||
public struct DocumentSaveResponse: Codable {
|
||||
public let status: String
|
||||
public let path: String
|
||||
public let title: String
|
||||
public let hash: String
|
||||
}
|
||||
|
||||
public struct SyncInitRequest: Codable {
|
||||
public let deviceId: String
|
||||
public let rootPath: String
|
||||
}
|
||||
|
||||
public struct SyncInitResponse: Codable {
|
||||
public let snapshotId: String
|
||||
public let serverSnapshot: [FileEntry]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case snapshotId
|
||||
case serverSnapshot
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
snapshotId = try container.decode(String.self, forKey: .snapshotId)
|
||||
serverSnapshot = try container.decodeIfPresent([FileEntry].self, forKey: .serverSnapshot) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
public struct FileEntry: Codable {
|
||||
public let path: String
|
||||
public let hash: String
|
||||
public let size: Int64
|
||||
public let modified: Date
|
||||
}
|
||||
|
||||
public struct DeltaRequest: Codable {
|
||||
public let snapshotId: String
|
||||
public let clientDelta: [Change]
|
||||
}
|
||||
|
||||
public struct DeltaResponse: Codable {
|
||||
public let serverDelta: [Change]
|
||||
public let conflicts: [Conflict]
|
||||
public let newSnapshotId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case serverDelta
|
||||
case conflicts
|
||||
case newSnapshotId
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
serverDelta = try container.decodeIfPresent([Change].self, forKey: .serverDelta) ?? []
|
||||
conflicts = try container.decodeIfPresent([Conflict].self, forKey: .conflicts) ?? []
|
||||
newSnapshotId = try container.decodeIfPresent(String.self, forKey: .newSnapshotId)
|
||||
}
|
||||
}
|
||||
|
||||
public struct Change: Codable {
|
||||
public let type: String
|
||||
public let path: String
|
||||
public let oldPath: String?
|
||||
public let hash: String?
|
||||
public let content: String?
|
||||
public let size: Int64?
|
||||
public let modified: Date?
|
||||
|
||||
public init(
|
||||
type: String,
|
||||
path: String,
|
||||
oldPath: String?,
|
||||
hash: String?,
|
||||
content: String? = nil,
|
||||
size: Int64?,
|
||||
modified: Date?
|
||||
) {
|
||||
self.type = type
|
||||
self.path = path
|
||||
self.oldPath = oldPath
|
||||
self.hash = hash
|
||||
self.content = content
|
||||
self.size = size
|
||||
self.modified = modified
|
||||
}
|
||||
}
|
||||
|
||||
public struct Conflict: Codable {
|
||||
public let path: String
|
||||
public let serverHash: String
|
||||
public let clientHash: String
|
||||
public let serverModified: Date
|
||||
public let clientModified: Date
|
||||
}
|
||||
|
||||
public struct APIError: Error, Codable {
|
||||
public let error: String
|
||||
public let conflictType: String?
|
||||
public let existingHash: String?
|
||||
public let existingPath: String?
|
||||
public let existingUrl: String?
|
||||
|
||||
public init(
|
||||
error: String,
|
||||
conflictType: String? = nil,
|
||||
existingHash: String? = nil,
|
||||
existingPath: String? = nil,
|
||||
existingUrl: String? = nil
|
||||
) {
|
||||
self.error = error
|
||||
self.conflictType = conflictType
|
||||
self.existingHash = existingHash
|
||||
self.existingPath = existingPath
|
||||
self.existingUrl = existingUrl
|
||||
}
|
||||
|
||||
public var isUploadConflict: Bool {
|
||||
conflictType == "document" || conflictType == "attachment"
|
||||
}
|
||||
}
|
||||
|
||||
extension APIError: LocalizedError {
|
||||
public var errorDescription: String? { error }
|
||||
}
|
||||
|
||||
public struct AttachmentsListResponse: Codable {
|
||||
public let attachments: [AttachmentRecord]
|
||||
}
|
||||
|
||||
public struct DocumentsListResponse: Codable {
|
||||
public let documents: [DocumentRecord]
|
||||
}
|
||||
|
||||
public enum UploadDuplicateAction: String {
|
||||
case overwrite
|
||||
case rename
|
||||
case reuse
|
||||
}
|
||||
Reference in New Issue
Block a user