282 lines
11 KiB
Swift
282 lines
11 KiB
Swift
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)
|
|
}
|
|
|
|
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 {}
|