Ship full-featured mobile clients with auth, payments, QRs, push notifications, and iOS polish including Figtree typography, app icons, and QR previews. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
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<UnreadCountEnvelope> = 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<T: Decodable>(_ path: String) async throws -> T {
|
||||
let envelope: Envelope<T> = try await request(path, method: "GET")
|
||||
return envelope.data
|
||||
}
|
||||
|
||||
private func envelopePost<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||
let envelope: Envelope<T> = try await request(path, method: "POST", body: body)
|
||||
return envelope.data
|
||||
}
|
||||
|
||||
private func envelopePut<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||
let envelope: Envelope<T> = try await request(path, method: "PUT", body: body)
|
||||
return envelope.data
|
||||
}
|
||||
|
||||
private func envelopePatch<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||
let envelope: Envelope<T> = 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<EmptyBody>.none)
|
||||
}
|
||||
|
||||
private func voidRequest<B: Encodable>(_ path: String, method: String, body: B) async throws {
|
||||
let _: Data = try await rawRequest(path, method: method, body: body)
|
||||
}
|
||||
|
||||
private func request<T: Decodable>(_ path: String, method: String) async throws -> T {
|
||||
let data = try await rawRequest(path, method: method, body: Optional<EmptyBody>.none)
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch let error as DecodingError {
|
||||
throw APIError.decoding(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func request<T: Decodable, B: Encodable>(
|
||||
_ 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<B: Encodable>(_ 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user