2026-06-18 18:04:59 +03:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-18 23:22:56 +03:00
|
|
|
|
|
|
|
|
/// 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
|
|
|
|
|
}
|
2026-06-18 18:04:59 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
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 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<T: Decodable>(_ 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<T: Decodable>(_ 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<T: Decodable>(_ 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|