import Foundation enum APIError: Error, LocalizedError { case badStatus(Int) case invalidUrl case noData var errorDescription: String? { switch self { case .badStatus(let code): "Server returned \(code)" case .invalidUrl: "Invalid server URL" case .noData: "No data received" } } } actor LinkdingAPI { private let config: ServerConfig private let session: URLSession private let decoder: JSONDecoder init(config: ServerConfig) { self.config = config self.session = URLSession.shared let d = JSONDecoder() d.dateDecodingStrategy = .custom { decoder in let s = try decoder.singleValueContainer().decode(String.self) let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = f.date(from: s) { return date } f.formatOptions = [.withInternetDateTime] if let date = f.date(from: s) { return date } throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Bad date: \(s)")) } self.decoder = d } private func makeUrl(path: String, queryItems: [URLQueryItem] = []) throws -> URL { var components = URLComponents() components.scheme = config.useHttps ? "https" : "http" components.host = config.host components.port = config.port components.path = config.path.isEmpty ? path : config.path + path if !queryItems.isEmpty { components.queryItems = queryItems } guard let url = components.url else { throw APIError.invalidUrl } return url } private func authorizedRequest(url: URL, method: String = "GET", body: Data? = nil) -> URLRequest { var req = URLRequest(url: url) req.httpMethod = method req.setValue("Token \(config.token)", forHTTPHeaderField: "Authorization") if let body { req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = body } return req } func fetchBookmarks(search: String = "", offset: Int = 0, limit: Int = 50) async throws -> BookmarkResponse { var items: [URLQueryItem] = [ .init(name: "limit", value: "\(limit)"), .init(name: "offset", value: "\(offset)") ] if !search.isEmpty { items.append(.init(name: "q", value: search)) } let url = try makeUrl(path: "/api/bookmarks/", queryItems: items) let (data, response) = try await session.data(for: authorizedRequest(url: url)) guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0) } return try decoder.decode(BookmarkResponse.self, from: data) } func fetchBookmarksFromUrl(_ urlString: String) async throws -> BookmarkResponse { guard let url = URL(string: urlString) else { throw APIError.invalidUrl } let (data, _) = try await session.data(for: authorizedRequest(url: url)) return try decoder.decode(BookmarkResponse.self, from: data) } func createBookmark(_ create: BookmarkCreate) async throws -> Bookmark { let body = try JSONEncoder().encode(create) let url = try makeUrl(path: "/api/bookmarks/") let (data, response) = try await session.data(for: authorizedRequest(url: url, method: "POST", body: body)) guard (response as? HTTPURLResponse)?.statusCode == 201 else { throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0) } return try decoder.decode(Bookmark.self, from: data) } func updateBookmark(id: Int, tagNames: [String]) async throws -> Bookmark { let body = try JSONEncoder().encode(["tag_names": tagNames]) let url = try makeUrl(path: "/api/bookmarks/\(id)/") let (data, response) = try await session.data(for: authorizedRequest(url: url, method: "PATCH", body: body)) guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0) } return try decoder.decode(Bookmark.self, from: data) } func deleteBookmark(id: Int) async throws { let url = try makeUrl(path: "/api/bookmarks/\(id)/") let (_, response) = try await session.data(for: authorizedRequest(url: url, method: "DELETE")) guard (response as? HTTPURLResponse)?.statusCode == 204 else { throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0) } } func archiveBookmark(id: Int) async throws { let url = try makeUrl(path: "/api/bookmarks/\(id)/archive/") let (_, response) = try await session.data(for: authorizedRequest(url: url, method: "POST")) guard let code = (response as? HTTPURLResponse)?.statusCode, code == 200 || code == 204 else { throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0) } } func verifyConnection() async throws { let url = try makeUrl(path: "/api/bookmarks/", queryItems: [.init(name: "limit", value: "1")]) let (_, response) = try await session.data(for: authorizedRequest(url: url)) guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0) } } }