// APIClient.swift // Jarvis — REST networking layer import Foundation enum APIError: Error, LocalizedError { case invalidURL case serverError(code: String, message: String, status: Int) case decodingError(Error) case networkError(Error) case notConnected var errorDescription: String? { switch self { case .invalidURL: return "Invalid server URL" case .notConnected: return "Not connected to a Jarvis server" case .serverError(_, let msg, _): return msg case .decodingError(let e): return "Decode error: \(e.localizedDescription)" case .networkError(let e): return e.localizedDescription } } /// A benign URLSession cancellation (-999), e.g. a request superseded or /// dropped on a network-path change. Should not be shown or wipe state. var isCancelled: Bool { if case .networkError(let e) = self { return (e as? URLError)?.code == .cancelled } return false } } private struct APIErrorResponse: Decodable { struct Inner: Decodable { let code, message: String; let status: Int } let error: Inner } actor APIClient { static let shared = APIClient() private var baseURL: URL? private let session: URLSession private let decoder: JSONDecoder private init() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 10 self.session = URLSession(configuration: config) self.decoder = JSONDecoder() self.decoder.dateDecodingStrategy = .iso8601 self.decoder.keyDecodingStrategy = .convertFromSnakeCase } func configure(host: String) throws { guard let url = URL(string: "http://\(host)/api/v1") else { throw APIError.invalidURL } self.baseURL = url } // MARK: - Health func checkHealth() async throws -> ServerHealth { try await get("/health") } // MARK: - Stories func fetchStories( cursor: String? = nil, topic: String? = nil, tags: Set = [], minSignal: Int? = nil, limit: Int = 20 ) async throws -> PaginatedStories { var params: [String: String] = ["limit": "\(limit)"] if let cursor { params["after"] = cursor } if let topic { params["topic"] = topic } if !tags.isEmpty { params["tags"] = tags.sorted().joined(separator: ",") } if let minSignal { params["min_signal"] = "\(minSignal)" } return try await get("/stories", params: params) } func fetchStory(id: String) async throws -> StoryDetail { try await get("/stories/\(id)") } // MARK: - Articles func fetchArticle(id: String) async throws -> Article { try await get("/articles/\(id)") } // MARK: - Feeds func fetchFeeds() async throws -> [Feed] { struct FeedsResponse: Decodable { let data: [Feed] } let response: FeedsResponse = try await get("/feeds") return response.data } func addFeed(url: String, name: String, pollInterval: Int = 900) async throws -> Feed { let body = ["url": url, "name": name, "pollIntervalSeconds": pollInterval] as [String: Any] return try await post("/feeds", body: body) } func deleteFeed(id: String) async throws { try await delete("/feeds/\(id)") } // MARK: - Private helpers private func get(_ path: String, params: [String: String] = [:]) async throws -> T { guard let base = baseURL else { throw APIError.notConnected } var components = URLComponents(url: base.appendingPathComponent(path), resolvingAgainstBaseURL: false)! if !params.isEmpty { components.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) } } guard let url = components.url else { throw APIError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "GET" return try await execute(request) } private func post(_ path: String, body: [String: Any]) async throws -> T { guard let base = baseURL else { throw APIError.notConnected } let url = base.appendingPathComponent(path) var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) return try await execute(request) } private func delete(_ path: String) async throws { guard let base = baseURL else { throw APIError.notConnected } let url = base.appendingPathComponent(path) var request = URLRequest(url: url) request.httpMethod = "DELETE" let (_, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 204 else { throw APIError.serverError(code: "delete_failed", message: "Delete failed", status: 500) } } private func execute(_ request: URLRequest) async throws -> T { do { let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw APIError.networkError(URLError(.badServerResponse)) } if !(200...299).contains(http.statusCode) { if let err = try? decoder.decode(APIErrorResponse.self, from: data) { throw APIError.serverError(code: err.error.code, message: err.error.message, status: err.error.status) } throw APIError.serverError(code: "unknown", message: "HTTP \(http.statusCode)", status: http.statusCode) } do { return try decoder.decode(T.self, from: data) } catch { throw APIError.decodingError(error) } } catch let error as APIError { throw error } catch { throw APIError.networkError(error) } } }