Files
psu-schedule/ios/PSUSchedule/API.swift

126 lines
6.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {}