import Foundation // MARK: - Service Protocol protocol ChallengeServiceProtocol: Sendable { func listChallenges(filter: ChallengeFilter) async throws -> [Challenge] func getChallenge(id: String) async throws -> (challenge: Challenge, participants: [ChallengeParticipant]) func createChallenge(request: CreateChallengeRequest) async throws -> Challenge func updateChallenge(id: String, request: UpdateChallengeRequest) async throws -> Challenge func joinChallenge(id: String) async throws func leaveChallenge(id: String) async throws func getLeaderboard(challengeId: String) async throws -> [LeaderboardEntry] func submitProgress(challengeId: String, progress: ProgressSubmission) async throws -> (progress: Double, percentage: Double) } // MARK: - Default Service class ChallengeService: ChallengeServiceProtocol { private let baseURL: URL private let session: URLSession private let authToken: String? init( baseURL: URL = URL(string: "http://localhost:3000")!, session: URLSession = .shared, authToken: String? = nil ) { self.baseURL = baseURL self.session = session self.authToken = authToken } func listChallenges(filter: ChallengeFilter = ChallengeFilter()) async throws -> [Challenge] { var components = URLComponents(url: baseURL.appendingPathComponent("/api/challenges"), resolvingAgainstBaseURL: true)! var queryItems: [URLQueryItem] = [ URLQueryItem(name: "limit", value: String(filter.limit)), URLQueryItem(name: "offset", value: String(filter.offset)) ] if let type = filter.challengeType { queryItems.append(URLQueryItem(name: "type", value: type.rawValue)) } if let status = filter.status { queryItems.append(URLQueryItem(name: "status", value: status.rawValue)) } if let participation = filter.participationStatus { queryItems.append(URLQueryItem(name: "participation", value: participation.rawValue)) } if let clubId = filter.clubId { queryItems.append(URLQueryItem(name: "clubId", value: clubId)) } components.queryItems = queryItems let request = try buildRequest(url: components.url!) let (data, response) = try await session.data(for: request) try validateResponse(response) let decoded = try JSONDecoder().decode(ChallengeListResponse.self, from: data) return decoded.challenges } func getChallenge(id: String) async throws -> (challenge: Challenge, participants: [ChallengeParticipant]) { let url = baseURL.appendingPathComponent("/api/challenges/\(id)") let request = try buildRequest(url: url) let (data, response) = try await session.data(for: request) try validateResponse(response) let decoded = try JSONDecoder().decode(ChallengeDetailResponse.self, from: data) return (decoded.challenge, decoded.participants) } func createChallenge(request: CreateChallengeRequest) async throws -> Challenge { let url = baseURL.appendingPathComponent("/api/challenges") var request = try buildRequest(url: url, method: .post) request.httpBody = try JSONEncoder().encode(request) let (data, response) = try await session.data(for: request) try validateResponse(response) let decoded = try JSONDecoder().decode(CreateChallengeResponse.self, from: data) return decoded.challenge } func updateChallenge(id: String, request: UpdateChallengeRequest) async throws -> Challenge { let url = baseURL.appendingPathComponent("/api/challenges/\(id)") var request = try buildRequest(url: url, method: .patch) request.httpBody = try JSONEncoder().encode(request) let (data, response) = try await session.data(for: request) try validateResponse(response) let decoded = try JSONDecoder().decode(UpdateChallengeResponse.self, from: data) return decoded.challenge } func joinChallenge(id: String) async throws { let url = baseURL.appendingPathComponent("/api/challenges/\(id)/join") let request = try buildRequest(url: url, method: .post) let (_, response) = try await session.data(for: request) try validateResponse(response) } func leaveChallenge(id: String) async throws { let url = baseURL.appendingPathComponent("/api/challenges/\(id)/leave") let request = try buildRequest(url: url, method: .post) let (_, response) = try await session.data(for: request) try validateResponse(response) } func getLeaderboard(challengeId: String) async throws -> [LeaderboardEntry] { let url = baseURL.appendingPathComponent("/api/challenges/\(challengeId)/leaderboard") let request = try buildRequest(url: url) let (data, response) = try await session.data(for: request) try validateResponse(response) let decoded = try JSONDecoder().decode(LeaderboardResponse.self, from: data) return decoded.entries } func submitProgress(challengeId: String, progress: ProgressSubmission) async throws -> (progress: Double, percentage: Double) { let url = baseURL.appendingPathComponent("/api/challenges/\(challengeId)/progress") var request = try buildRequest(url: url, method: .post) request.httpBody = try JSONEncoder().encode(progress) let (data, response) = try await session.data(for: request) try validateResponse(response) let decoded = try JSONDecoder().decode(ProgressResponse.self, from: data) return (decoded.newProgress, decoded.progressPercentage) } // MARK: - Helpers private func buildRequest(url: URL, method: HTTPMethod = .get, body: Data? = nil) throws -> URLRequest { var request = URLRequest(url: url) request.httpMethod = method.rawValue request.setValue("application/json", forHTTPHeaderField: "Content-Type") if let token = authToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } if let body = body { request.httpBody = body } return request } private func validateResponse(_ response: URLResponse) throws { guard let httpResponse = response as? HTTPURLResponse else { throw ChallengeError.invalidResponse } guard (200...299).contains(httpResponse.statusCode) else { switch httpResponse.statusCode { case 401: throw ChallengeError.unauthorized case 403: throw ChallengeError.forbidden case 404: throw ChallengeError.notFound case 429: throw ChallengeError.rateLimited case 500...599: throw ChallengeError.serverError(httpResponse.statusCode) default: throw ChallengeError.httpError(httpResponse.statusCode) } } } } // MARK: - Error Types enum ChallengeError: LocalizedError { case invalidResponse case unauthorized case forbidden case notFound case rateLimited case serverError(Int) case httpError(Int) var errorDescription: String { switch self { case .invalidResponse: return "Invalid server response" case .unauthorized: return "Unauthorized — please log in again" case .forbidden: return "Forbidden — check permissions" case .notFound: return "Challenge not found" case .rateLimited: return "Too many requests — try again shortly" case .serverError(let code): return "Server error (\(code))" case .httpError(let code): return "HTTP error (\(code))" } } }