import Foundation import OSLog /// An error with a message ready to show. `code` is the server's machine-readable reason /// (server/errors.js); `offline` means the data could not be fetched — no network, or the /// university's catalog is down — so the saved copy stays and "Повторить" makes sense. struct APIError: LocalizedError { var message: String var offline = false var code: String? var errorDescription: String? { message } } /// The schedule server's HTTP API, as the Android client's Api.kt. struct API: Sendable { let base: URL /// The server address: Info.plist's PSUAPIURL; Debug builds may set PSU_API_URL instead. static let shared: API = { #if DEBUG if let env = ProcessInfo.processInfo.environment["PSU_API_URL"], let url = URL(string: env) { return API(base: url) } #endif let plist = Bundle.main.object(forInfoDictionaryKey: "PSUAPIURL") as? String return API(base: URL(string: plist ?? "https://psu.rumblur.by/")!) }() private static let log = Logger(subsystem: "org.xtimms.psu.schedule", category: "API") private static let offline = "Нет связи с сервером расписания." /// Server codes after which trying again later can help: the university is down or slow, the /// server is busy or did not answer in time, or this address sent too many requests. private static let sourceDown: Set = ["source_unavailable", "source_timeout", "busy", "timeout", "too_many_requests"] private static let retries = 3 // The server answers "no-cache" with an ETag: URLSession keeps the answer and asks again // with If-None-Match, so an unchanged semester comes back as an empty 304. private static let session: URLSession = { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 60 config.urlCache = URLCache(memoryCapacity: 2 << 20, diskCapacity: 10 << 20) config.requestCachePolicy = .useProtocolCachePolicy return URLSession(configuration: config) }() private func get(_ path: String, _ query: [(String, String?)] = []) async throws -> T { var components = URLComponents(url: base.appendingPathComponent(path), resolvingAgainstBaseURL: false)! let items = query.compactMap { name, value in value.flatMap { $0.isEmpty ? nil : URLQueryItem(name: name, value: $0) } } if !items.isEmpty { components.queryItems = items } let url = components.url! let (data, response) = try await withRetry(url) guard let http = response as? HTTPURLResponse else { throw APIError(message: Self.offline, offline: true, code: "offline") } guard (200..<300).contains(http.statusCode) else { let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] let message = body?["error"] as? String // Servers before API version 5 send no code; their catch-all 404 has this text. let code = body?["code"] as? String ?? (http.statusCode == 404 && message == "Адрес не найден." ? "unknown_endpoint" : nil) if code == "unknown_endpoint" { throw APIError(message: "Сервер расписания не знает запрос /\(path) — на нём старая версия. Обновите сервер.", code: code) } throw APIError( message: message ?? "Сервер ответил ошибкой \(http.statusCode).", offline: code.map(Self.sourceDown.contains) ?? false, code: code ) } do { return try JSONDecoder().decode(T.self, from: data) } catch { Self.log.error("Decoding \(path) failed: \(error)") throw APIError(message: "Не удалось прочитать ответ сервера.") } } /// A dropped or timed-out connection is retried twice (after 1 and 3 seconds): /// a flaky mobile network often recovers within seconds. Server answers are not retried. private func withRetry(_ url: URL) async throws -> (Data, URLResponse) { var wait: UInt64 = 1_000_000_000 for attempt in 1...Self.retries { do { return try await Self.session.data(from: url) } catch let error as URLError where error.code != .cancelled { Self.log.warning("GET \(url) failed (attempt \(attempt)): \(error.localizedDescription)") if attempt == Self.retries { break } try await Task.sleep(nanoseconds: wait) wait *= 3 } } throw APIError(message: Self.offline, offline: true, code: "offline") } /// The whole current semester; a server without range=semester answers the from/days window. func timetable(doc: String, group: String, from: CivilDate, days: Int) async throws -> Timetable { try await get("api/timetable", [("doc", doc), ("group", group), ("from", from.iso), ("days", String(days)), ("range", "semester")]) } func groups(query: String = "", faculty: String = "", course: String = "") async throws -> GroupsResponse { try await get("api/groups", [("q", query), ("faculty", faculty), ("course", course)]) } func searchDirectory(_ query: String) async throws -> DirectorySearch { try await get("api/directory/search", [("q", query)]) } func teacherDay(_ name: String, date: CivilDate) async throws -> DirectoryDay { try await get("api/directory/day", [("teacher", name), ("date", date.iso)]) } func roomDay(_ room: String, date: CivilDate) async throws -> DirectoryDay { try await get("api/directory/day", [("room", room), ("date", date.iso)]) } func calendar() async throws -> CalendarInfo { try await get("api/calendar") } func exams(group: String, faculty: String) async throws -> ExamsResponse { try await get("api/exams", [("group", group), ("faculty", faculty)]) } } extension Timetable: @unchecked Sendable {} extension GroupsResponse: @unchecked Sendable {} extension DirectorySearch: @unchecked Sendable {} extension DirectoryDay: @unchecked Sendable {} extension CalendarInfo: @unchecked Sendable {} extension ExamsResponse: @unchecked Sendable {}