Files
psu-schedule/ios/PSUSchedule/API.swift
Zakhar Timoshenko 170a44c377 Add date and time handling, models, schedule management, storage, and widget support
- Introduced `Dates.swift` for handling date and time in the university's timezone.
- Created `Models.swift` to define data structures for timetable, lessons, and directory search results.
- Implemented `Schedule.swift` for managing lesson slots and timetable functionalities.
- Developed `Storage.swift` for user settings and data persistence.
- Added `Widgets.swift` to create widgets displaying lessons and upcoming classes.
2026-10-18 20:26:12 +03:00

114 lines
5.4 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; `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<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) }
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 {}