| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494 |
- import Foundation
- enum GoogleClassroomClientError: Error {
- case invalidResponse
- case httpStatus(Int, String)
- case decodeFailed(String)
- }
- /// Minimal Google Classroom REST wrapper for a student's to-do list.
- final class GoogleClassroomClient {
- struct Options: Sendable {
- var maxCourses: Int
- var maxCourseWorkPerCourse: Int
- init(maxCourses: Int = 20, maxCourseWorkPerCourse: Int = 50) {
- self.maxCourses = maxCourses
- self.maxCourseWorkPerCourse = maxCourseWorkPerCourse
- }
- }
- private let session: URLSession
- init(session: URLSession = .shared) {
- self.session = session
- }
- func fetchTodo(accessToken: String, options: Options? = nil) async throws -> [ClassroomTodoItem] {
- let resolvedOptions = options ?? Options()
- let courses = try await listActiveCourses(accessToken: accessToken, pageSize: resolvedOptions.maxCourses)
- if courses.isEmpty { return [] }
- var todos: [ClassroomTodoItem] = []
- todos.reserveCapacity(courses.count * 8)
- for course in courses.prefix(max(1, resolvedOptions.maxCourses)) {
- let courseWork = try await listPublishedCourseWork(
- accessToken: accessToken,
- courseId: course.id,
- pageSize: resolvedOptions.maxCourseWorkPerCourse
- )
- for work in courseWork {
- let due = work.resolvedDueDate
- let link = work.alternateLink.flatMap(URL.init(string:))
- let workType = ClassroomTodoWorkType(rawValue: work.workType ?? "") ?? .unspecified
- todos.append(
- ClassroomTodoItem(
- id: "\(course.id)::\(work.id ?? UUID().uuidString)",
- courseId: course.id,
- courseName: course.name ?? "Course",
- title: (work.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? (work.title ?? "Untitled") : "Untitled",
- dueDate: due,
- alternateLink: link,
- workType: workType
- )
- )
- }
- }
- // Keep only items with a due date near-term first; undated items at end.
- todos.sort {
- switch ($0.dueDate, $1.dueDate) {
- case let (a?, b?):
- if a != b { return a < b }
- return $0.courseName < $1.courseName
- case (_?, nil):
- return true
- case (nil, _?):
- return false
- case (nil, nil):
- if $0.courseName != $1.courseName { return $0.courseName < $1.courseName }
- return $0.title < $1.title
- }
- }
- return todos
- }
- func fetchEnrolledCourses(accessToken: String, pageSize: Int = 50) async throws -> [ClassroomCourse] {
- let courses = try await listActiveCourses(accessToken: accessToken, pageSize: pageSize)
- if courses.isEmpty { return [] }
- var enrolled: [ClassroomCourse] = []
- enrolled.reserveCapacity(courses.count)
- for course in courses {
- let teachers = try await listCourseTeachers(accessToken: accessToken, courseId: course.id)
- let teacherNames = teachers.compactMap { teacher -> String? in
- let profileName = teacher.profile?.name?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines)
- if let profileName, profileName.isEmpty == false { return profileName }
- let email = teacher.profile?.emailAddress?.trimmingCharacters(in: .whitespacesAndNewlines)
- if let email, email.isEmpty == false { return email }
- return nil
- }
- enrolled.append(
- ClassroomCourse(
- id: course.id,
- name: (course.name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? (course.name ?? "Course") : "Course",
- section: course.section,
- room: course.room,
- teacherNames: teacherNames,
- enrollmentCode: course.enrollmentCode,
- courseState: course.courseState ?? "ACTIVE"
- )
- )
- }
- enrolled.sort {
- if $0.name != $1.name { return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
- return $0.id < $1.id
- }
- return enrolled
- }
- func fetchClassDetails(accessToken: String, courseId: String, courseName: String) async throws -> ClassroomClassDetails {
- async let announcementsTask = listCourseAnnouncements(accessToken: accessToken, courseId: courseId, pageSize: 30)
- async let courseworkTask = listPublishedCourseWork(accessToken: accessToken, courseId: courseId, pageSize: 30)
- let (announcements, coursework) = try await (announcementsTask, courseworkTask)
- let mappedAnnouncements = announcements.map { item in
- let text = item.text?.trimmingCharacters(in: .whitespacesAndNewlines)
- return ClassroomAnnouncement(
- id: item.id ?? UUID().uuidString,
- text: (text?.isEmpty == false) ? text! : "Announcement",
- postedAt: item.createdDate,
- alternateLink: item.alternateLink.flatMap(URL.init(string:)),
- attachments: mapMaterialsToAttachments(item.materials)
- )
- }.sorted { ($0.postedAt ?? .distantPast) > ($1.postedAt ?? .distantPast) }
- let mappedCourseWork = coursework.map { work in
- let title = work.title?.trimmingCharacters(in: .whitespacesAndNewlines)
- let workType = ClassroomTodoWorkType(rawValue: work.workType ?? "") ?? .unspecified
- return ClassroomCourseWork(
- id: work.id ?? UUID().uuidString,
- title: (title?.isEmpty == false) ? title! : "Untitled",
- subtitle: workType.displayName,
- dueDate: work.resolvedDueDate,
- alternateLink: work.alternateLink.flatMap(URL.init(string:)),
- workType: workType,
- attachments: mapMaterialsToAttachments(work.materials)
- )
- }.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
- return ClassroomClassDetails(
- courseId: courseId,
- courseName: courseName,
- courseLink: URL(string: "https://classroom.google.com/c/\(courseId)"),
- announcements: mappedAnnouncements,
- coursework: mappedCourseWork
- )
- }
- // MARK: - Courses
- private func listActiveCourses(accessToken: String, pageSize: Int) async throws -> [Course] {
- var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses")!
- components.queryItems = [
- URLQueryItem(name: "studentId", value: "me"),
- URLQueryItem(name: "courseStates", value: "ACTIVE"),
- URLQueryItem(name: "pageSize", value: String(max(1, min(100, pageSize))))
- ]
- var request = URLRequest(url: components.url!)
- request.httpMethod = "GET"
- request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
- let (data, response) = try await session.data(for: request)
- guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
- guard (200..<300).contains(http.statusCode) else {
- let body = String(data: data, encoding: .utf8) ?? "<no body>"
- throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
- }
- let decoded: CoursesList
- do {
- decoded = try JSONDecoder().decode(CoursesList.self, from: data)
- } catch {
- let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
- throw GoogleClassroomClientError.decodeFailed(raw)
- }
- return decoded.courses ?? []
- }
- private func listCourseTeachers(accessToken: String, courseId: String) async throws -> [Teacher] {
- var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)/teachers")!
- components.queryItems = [
- URLQueryItem(name: "pageSize", value: "30")
- ]
- var request = URLRequest(url: components.url!)
- request.httpMethod = "GET"
- request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
- let (data, response) = try await session.data(for: request)
- guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
- guard (200..<300).contains(http.statusCode) else {
- let body = String(data: data, encoding: .utf8) ?? "<no body>"
- throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
- }
- let decoded: TeachersList
- do {
- decoded = try JSONDecoder().decode(TeachersList.self, from: data)
- } catch {
- let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
- throw GoogleClassroomClientError.decodeFailed(raw)
- }
- return decoded.teachers ?? []
- }
- private func listCourseAnnouncements(accessToken: String, courseId: String, pageSize: Int) async throws -> [CourseAnnouncement] {
- var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)/announcements")!
- components.queryItems = [
- URLQueryItem(name: "announcementStates", value: "PUBLISHED"),
- URLQueryItem(name: "orderBy", value: "updateTime desc"),
- URLQueryItem(name: "pageSize", value: String(max(1, min(100, pageSize))))
- ]
- var request = URLRequest(url: components.url!)
- request.httpMethod = "GET"
- request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
- let (data, response) = try await session.data(for: request)
- guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
- guard (200..<300).contains(http.statusCode) else {
- let body = String(data: data, encoding: .utf8) ?? "<no body>"
- throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
- }
- let decoded: CourseAnnouncementList
- do {
- decoded = try JSONDecoder().decode(CourseAnnouncementList.self, from: data)
- } catch {
- let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
- throw GoogleClassroomClientError.decodeFailed(raw)
- }
- return decoded.announcements ?? []
- }
- // MARK: - CourseWork
- private func listPublishedCourseWork(accessToken: String, courseId: String, pageSize: Int) async throws -> [CourseWork] {
- var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)/courseWork")!
- components.queryItems = [
- URLQueryItem(name: "courseWorkStates", value: "PUBLISHED"),
- URLQueryItem(name: "orderBy", value: "dueDate desc"),
- URLQueryItem(name: "pageSize", value: String(max(1, min(100, pageSize))))
- ]
- var request = URLRequest(url: components.url!)
- request.httpMethod = "GET"
- request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
- let (data, response) = try await session.data(for: request)
- guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
- guard (200..<300).contains(http.statusCode) else {
- let body = String(data: data, encoding: .utf8) ?? "<no body>"
- throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
- }
- let decoded: CourseWorkList
- do {
- decoded = try JSONDecoder().decode(CourseWorkList.self, from: data)
- } catch {
- let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
- throw GoogleClassroomClientError.decodeFailed(raw)
- }
- return decoded.courseWork ?? []
- }
- private func mapMaterialsToAttachments(_ materials: [Material]?) -> [ClassroomAttachment] {
- guard let materials else { return [] }
- var attachments: [ClassroomAttachment] = []
- attachments.reserveCapacity(materials.count)
- for (index, material) in materials.enumerated() {
- if let drive = material.driveFile?.driveFile,
- let linkString = drive.alternateLink,
- let url = URL(string: linkString) {
- attachments.append(
- ClassroomAttachment(
- id: drive.id ?? "drive-\(index)",
- title: drive.title?.nonEmptyOr("Drive file") ?? "Drive file",
- url: url,
- mimeType: drive.mimeType,
- sourceType: "driveFile"
- )
- )
- } else if let link = material.link,
- let linkString = link.url,
- let url = URL(string: linkString) {
- attachments.append(
- ClassroomAttachment(
- id: "link-\(index)",
- title: link.title?.nonEmptyOr("Link") ?? "Link",
- url: url,
- mimeType: nil,
- sourceType: "link"
- )
- )
- } else if let form = material.form,
- let linkString = form.formUrl,
- let url = URL(string: linkString) {
- attachments.append(
- ClassroomAttachment(
- id: "form-\(index)",
- title: form.title?.nonEmptyOr("Google Form") ?? "Google Form",
- url: url,
- mimeType: nil,
- sourceType: "form"
- )
- )
- } else if let yt = material.youtubeVideo,
- let linkString = yt.alternateLink,
- let url = URL(string: linkString) {
- attachments.append(
- ClassroomAttachment(
- id: "youtube-\(index)",
- title: yt.title?.nonEmptyOr("YouTube") ?? "YouTube",
- url: url,
- mimeType: nil,
- sourceType: "youtubeVideo"
- )
- )
- }
- }
- return attachments
- }
- }
- extension GoogleClassroomClientError: LocalizedError {
- var errorDescription: String? {
- switch self {
- case .invalidResponse:
- return "Google Classroom returned an invalid response."
- case let .httpStatus(status, body):
- return "Google Classroom API error (\(status)): \(body)"
- case let .decodeFailed(raw):
- return "Failed to parse Google Classroom response: \(raw)"
- }
- }
- }
- // MARK: - REST models (minimal)
- private struct CoursesList: Decodable {
- let courses: [Course]?
- let nextPageToken: String?
- }
- private struct Course: Decodable {
- let id: String
- let name: String?
- let section: String?
- let room: String?
- let enrollmentCode: String?
- let courseState: String?
- }
- private struct CourseWorkList: Decodable {
- let courseWork: [CourseWork]?
- let nextPageToken: String?
- }
- private struct CourseWork: Decodable {
- let id: String?
- let title: String?
- let alternateLink: String?
- let workType: String?
- let dueDate: DateParts?
- let dueTime: TimeOfDay?
- let materials: [Material]?
- var resolvedDueDate: Date? {
- guard let dueDate else { return nil }
- var comps = DateComponents()
- comps.calendar = Calendar.current
- comps.timeZone = TimeZone.current
- comps.year = dueDate.year
- comps.month = dueDate.month
- comps.day = dueDate.day
- if let dueTime {
- comps.hour = dueTime.hours
- comps.minute = dueTime.minutes
- comps.second = dueTime.seconds
- } else {
- comps.hour = 23
- comps.minute = 59
- comps.second = 0
- }
- return comps.date
- }
- }
- private struct CourseAnnouncementList: Decodable {
- let announcements: [CourseAnnouncement]?
- let nextPageToken: String?
- }
- private struct CourseAnnouncement: Decodable {
- let id: String?
- let text: String?
- let alternateLink: String?
- let creationTime: String?
- let materials: [Material]?
- var createdDate: Date? {
- guard let creationTime else { return nil }
- return Date.parseRFC3339(creationTime)
- }
- }
- private struct DateParts: Decodable {
- let year: Int?
- let month: Int?
- let day: Int?
- }
- private struct TimeOfDay: Decodable {
- let hours: Int?
- let minutes: Int?
- let seconds: Int?
- let nanos: Int?
- }
- private struct Material: Decodable {
- let driveFile: SharedDriveFileContainer?
- let link: SharedLink?
- let form: SharedForm?
- let youtubeVideo: SharedYoutubeVideo?
- }
- private struct SharedDriveFileContainer: Decodable {
- let driveFile: SharedDriveFile?
- }
- private struct SharedDriveFile: Decodable {
- let id: String?
- let title: String?
- let alternateLink: String?
- let mimeType: String?
- }
- private struct SharedLink: Decodable {
- let url: String?
- let title: String?
- }
- private struct SharedForm: Decodable {
- let formUrl: String?
- let title: String?
- }
- private struct SharedYoutubeVideo: Decodable {
- let alternateLink: String?
- let title: String?
- }
- private struct TeachersList: Decodable {
- let teachers: [Teacher]?
- let nextPageToken: String?
- }
- private struct Teacher: Decodable {
- let profile: UserProfile?
- }
- private struct UserProfile: Decodable {
- let name: UserName?
- let emailAddress: String?
- }
- private struct UserName: Decodable {
- let fullName: String?
- }
- private extension String {
- func nonEmptyOr(_ fallback: String) -> String {
- let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmed.isEmpty ? fallback : trimmed
- }
- }
- private extension Date {
- static func parseRFC3339(_ text: String) -> Date? {
- let formatterWithFractional = ISO8601DateFormatter()
- formatterWithFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- if let value = formatterWithFractional.date(from: text) { return value }
- let formatter = ISO8601DateFormatter()
- formatter.formatOptions = [.withInternetDateTime]
- return formatter.date(from: text)
- }
- }
|