Deploy Ladill Mini / deploy (push) Successful in 35s
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>
278 lines
9.9 KiB
Swift
278 lines
9.9 KiB
Swift
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
|
|
}
|
|
}
|