import Foundation import OSLog /// An error with a message ready to show; `offline` when the server could not be reached. struct APIError: LocalizedError { var message: String var offline = false 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 = "Нет связи с сервером расписания." 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) } guard (200..<300).contains(http.statusCode) else { let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["error"] as? String // The server's catch-all 404 means it runs an older version without this endpoint. if http.statusCode == 404 && message == "Адрес не найден." { throw APIError(message: "Сервер расписания не знает запрос /\(path) — на нём старая версия. Обновите сервер.") } throw APIError(message: message ?? "Сервер ответил ошибкой \(http.statusCode).") } 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) } /// 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 {}