126 lines
6.1 KiB
Swift
126 lines
6.1 KiB
Swift
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.
|
||
private static let sourceDown: Set<String> = ["source_unavailable", "source_timeout", "busy"]
|
||
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<T: Decodable & Sendable>(_ 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 {}
|