Files

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 {}