import Foundation enum APIError: LocalizedError { case invalidURL case http(Int, String?) case decoding(Error) case network(Error) var errorDescription: String? { switch self { case .invalidURL: return "Invalid request." case .http(let code, let message): if let message, !message.isEmpty { return message } if code == 401 { return "Your session expired. Please sign in again." } if code == 403 { return "You don't have permission to do that." } return "Request failed (\(code))." case .decoding: return "Could not read the server response." case .network: return "No connection. Check your network and try again." } } } final class APIClient { static let shared = APIClient() private let session: URLSession private let decoder: JSONDecoder private let encoder: JSONEncoder private var baseURL: String { Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as? String ?? "https://mini.ladill.com/api/v1/" } private init() { session = URLSession.shared decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase encoder = JSONEncoder() encoder.keyEncodingStrategy = .convertToSnakeCase } // MARK: - Auth func login(email: String, password: String) async throws -> LoginResult { try await envelopePost("auth/login", body: LoginRequest(email: email, password: password)) } func register(_ body: RegisterRequest) async throws -> LoginResult { try await envelopePost("auth/register", body: body) } func logout() async { _ = try? await voidRequest("auth/logout", method: "POST") } func me() async throws -> User { try await envelopeGet("me") } // MARK: - Mini func overview() async throws -> Overview { try await envelopeGet("mini/overview") } func afiaChat(message: String, history: [AfiaChatTurn]) async throws -> AfiaChatResponse { try await request("mini/afia/chat", method: "POST", body: AfiaChatRequest(message: message, history: history)) } func paymentQrs() async throws -> [PaymentQr] { try await envelopeGet("mini/payment-qrs") } func createPaymentQr(_ body: CreatePaymentQrRequest) async throws -> PaymentQr { try await envelopePost("mini/payment-qrs", body: body) } func paymentQr(id: Int64) async throws -> PaymentQr { try await envelopeGet("mini/payment-qrs/\(id)") } func updatePaymentQr(id: Int64, body: UpdatePaymentQrRequest) async throws -> PaymentQr { try await envelopePatch("mini/payment-qrs/\(id)", body: body) } func deletePaymentQr(id: Int64) async throws { try await voidRequest("mini/payment-qrs/\(id)", method: "DELETE") } func payments(query: String? = nil) async throws -> [Payment] { var path = "mini/payments" if let query, !query.isEmpty, let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { path += "?q=\(encoded)" } return try await envelopeGet(path) } func payouts() async throws -> Payouts { try await envelopeGet("mini/payouts") } func accountSettings() async throws -> AccountSettings { try await envelopeGet("mini/account/settings") } func updateSettings(_ body: UpdateSettingsRequest) async throws -> NotificationPrefs { try await envelopePut("mini/account/settings", body: body) } func updateProfile(_ body: UpdateProfileRequest) async throws -> ProfileInfo { try await envelopePut("mini/account/profile", body: body) } func changePassword(_ body: ChangePasswordRequest) async throws -> MessageResponse { try await envelopePost("mini/account/change-password", body: body) } func wallet() async throws -> WalletInfo { try await envelopeGet("mini/wallet") } func walletTopup(amount: Double) async throws -> CheckoutUrl { try await envelopePost("mini/wallet/topup", body: TopupRequest(amount: amount)) } func banks(type: String) async throws -> [Bank] { try await envelopeGet("mini/wallet/banks?type=\(type)") } func payoutAccount() async throws -> PayoutAccount? { let wrapper: PayoutAccountWrapper = try await envelopeGet("mini/wallet/payout-account") return wrapper.payoutAccount } func updatePayoutAccount(_ body: UpdatePayoutAccountRequest) async throws -> PayoutAccount? { let wrapper: PayoutAccountWrapper = try await envelopePut("mini/wallet/payout-account", body: body) return wrapper.payoutAccount } func withdrawals() async throws -> [Withdrawal] { try await envelopeGet("mini/wallet/withdrawals") } func withdraw(amount: Double) async throws -> Withdrawal { try await envelopePost("mini/wallet/withdraw", body: WithdrawRequest(amount: amount)) } func supportTickets() async throws -> [SupportTicket] { try await envelopeGet("mini/support/tickets") } func createSupportTicket(_ body: CreateTicketRequest) async throws -> SupportTicket { try await envelopePost("mini/support/tickets", body: body) } func supportTicket(id: Int64) async throws -> SupportTicket { try await envelopeGet("mini/support/tickets/\(id)") } func notifications() async throws -> NotificationsListResponse { try await request("mini/notifications", method: "GET") } func unreadNotificationCount() async throws -> Int { let envelope: Envelope = try await request("mini/notifications/unread-count", method: "GET") return envelope.data.unreadCount ?? 0 } func markNotificationRead(id: String) async throws { try await voidRequest("mini/notifications/\(id)/read", method: "POST") } func markAllNotificationsRead() async throws { try await voidRequest("mini/notifications/mark-all-read", method: "POST") } func registerPushToken(_ body: PushTokenRequest) async throws { try await voidRequest("mini/push-token", method: "POST", body: body) } func unregisterPushToken(token: String) async throws { try await voidRequest("mini/push-token", method: "DELETE", body: PushTokenRequest(token: token)) } // MARK: - Helpers private func envelopeGet(_ path: String) async throws -> T { let envelope: Envelope = try await request(path, method: "GET") return envelope.data } private func envelopePost(_ path: String, body: B) async throws -> T { let envelope: Envelope = try await request(path, method: "POST", body: body) return envelope.data } private func envelopePut(_ path: String, body: B) async throws -> T { let envelope: Envelope = try await request(path, method: "PUT", body: body) return envelope.data } private func envelopePatch(_ path: String, body: B) async throws -> T { let envelope: Envelope = try await request(path, method: "PATCH", body: body) return envelope.data } private func voidRequest(_ path: String, method: String) async throws { let _: Data = try await rawRequest(path, method: method, body: Optional.none) } private func voidRequest(_ path: String, method: String, body: B) async throws { let _: Data = try await rawRequest(path, method: method, body: body) } private func request(_ path: String, method: String) async throws -> T { let data = try await rawRequest(path, method: method, body: Optional.none) do { return try decoder.decode(T.self, from: data) } catch let error as DecodingError { throw APIError.decoding(error) } } private func request( _ path: String, method: String, body: B ) async throws -> T { let data = try await rawRequest(path, method: method, body: body) do { return try decoder.decode(T.self, from: data) } catch let error as DecodingError { throw APIError.decoding(error) } } private struct EmptyBody: Encodable {} private func rawRequest(_ path: String, method: String, body: B?) async throws -> Data { guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } var request = URLRequest(url: url) request.httpMethod = method request.setValue("application/json", forHTTPHeaderField: "Accept") if body != nil { request.setValue("application/json", forHTTPHeaderField: "Content-Type") } let token = await SessionStore.shared.token if let token, !token.isEmpty { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } if let body { request.httpBody = try encoder.encode(body) } do { let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw APIError.http(0, nil) } if http.statusCode >= 400 { throw APIError.http(http.statusCode, Self.parseErrorMessage(data)) } return data } catch let error as APIError { throw error } catch { throw APIError.network(error) } } private static func parseErrorMessage(_ data: Data) -> String? { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } if let errors = json["errors"] as? [String: Any], let first = errors.values.first as? [String], let message = first.first { return message } return json["message"] as? String } }