import Foundation enum ApiServiceError: Error, LocalizedError { case sessionExpired(String?) var errorDescription: String? { switch self { case .sessionExpired(let message): return message ?? "Sessao expirada. Faca login novamente." } } } struct ApiEnvelope: Decodable { let error: Bool let code: String? let message: String? let result: T? } final class ApiService { private let client: ApiClient private var tokenStore: TokenStore init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) { self.client = client self.tokenStore = tokenStore } private func send(_ req: ApiRequest) async throws -> T { do { return try await client.send(req) } catch let error as NetworkError { if case .unauthorized(let message) = error { expireSession(message) throw ApiServiceError.sessionExpired(message) } throw error } } private func sendEnvelope(_ req: ApiRequest) async throws -> ApiEnvelope { let envelope: ApiEnvelope = try await send(req) if isSessionExpiredEnvelope(envelope) { expireSession(envelope.message) throw ApiServiceError.sessionExpired(envelope.message) } return envelope } private func isSessionExpiredEnvelope(_ envelope: ApiEnvelope) -> Bool { guard envelope.error else { return false } let code = (envelope.code ?? "").lowercased() let message = (envelope.message ?? "").lowercased() if code.contains("auth") || code.contains("token") || code.contains("unauthorized") { return true } if message.contains("token") && (message.contains("expir") || message.contains("invalid") || message.contains("sess")) { return true } return false } private func expireSession(_ message: String?) { tokenStore.clear() NotificationCenter.default.post(name: .sessionExpired, object: message) } // MARK: - Auth func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope { var payload: [String: String] = [ "name": name, "email": email, "phoneNumber": phoneNumber ] if let birthDate, birthDate.isEmpty == false { payload["birthDate"] = birthDate } let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/customer", method: "POST", module: .customer, requiresAuth: false, body: body) return try await sendEnvelope(req) } func requestOtp(email: String, phoneNumber: String) async throws -> ApiEnvelope { let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: nil) let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body) return try await sendEnvelope(req) } func validateOtp(email: String, phoneNumber: String, otp: String) async throws -> ApiEnvelope { let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: otp) let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body) let response: ApiEnvelope = try await sendEnvelope(req) if let token = response.result?.token { tokenStore.jwt = token } return response } private func makeLoginBody(email: String, phoneNumber: String, otp: String?) throws -> Data { let sanitizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) let sanitizedPhone = phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines) guard sanitizedEmail.isEmpty == false, sanitizedPhone.isEmpty == false else { throw NetworkError.httpError(400, "Email e telefone são obrigatórios.") } var payload: [String: String] = [ "email": sanitizedEmail, "phoneNumber": sanitizedPhone, "phone": sanitizedPhone ] if let otp, otp.isEmpty == false { payload["otp"] = otp } guard JSONSerialization.isValidJSONObject(payload) else { throw NetworkError.invalidResponse } return try JSONSerialization.data(withJSONObject: payload, options: []) } func profile() async throws -> ApiEnvelope { let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true) return try await sendEnvelope(req) } func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope { return try await saveCustomerAddress(address, replacingAddressId: nil) } func saveCustomerAddress(_ address: CustomerAddress, replacingAddressId: String?) async throws -> ApiEnvelope { let currentProfile = try await profile() guard currentProfile.error == false, let customer = currentProfile.result else { throw NetworkError.invalidResponse } let currentAddressBook = customer.addressBook ?? [] var addressBook = currentAddressBook.map(CustomerAddressPayload.init(from:)) let addressPayload = CustomerAddressPayload(from: address) if let replacingAddressId, let replaceIndex = currentAddressBook.firstIndex(where: { $0.id == replacingAddressId }) { addressBook[replaceIndex] = addressPayload } else { addressBook.insert(addressPayload, at: 0) } return try await updateCustomerAddressBook(customerId: customer.id, addressBook: addressBook) } func deleteCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope { let currentProfile = try await profile() guard currentProfile.error == false, let customer = currentProfile.result else { throw NetworkError.invalidResponse } var currentAddressBook = customer.addressBook ?? [] if let targetId = address.id, targetId.isEmpty == false { if let index = currentAddressBook.firstIndex(where: { $0.id == targetId }) { currentAddressBook.remove(at: index) } } else if let index = currentAddressBook.firstIndex(where: { matchesAddress($0, address) }) { currentAddressBook.remove(at: index) } let updatedAddressBook = currentAddressBook.map(CustomerAddressPayload.init(from:)) return try await updateCustomerAddressBook(customerId: customer.id, addressBook: updatedAddressBook) } func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope { let digits = zipCode.filter(\.isNumber) let normalized = String(digits.prefix(8)) let formatted: String if normalized.count == 8 { let prefix = String(normalized.prefix(5)) let suffix = String(normalized.dropFirst(5)) formatted = "\(prefix)-\(suffix)" } else { formatted = normalized } let req = ApiRequest(path: "/api/public/cep/\(formatted)", method: "GET", module: .none, requiresAuth: true) return try await sendEnvelope(req) } private func updateCustomerAddressBook(customerId: String, addressBook: [CustomerAddressPayload]) async throws -> ApiEnvelope { let payload = CustomerProfileUpdatePayload(addressBook: addressBook) let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body) return try await sendEnvelope(req) } private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool { lhs.label == rhs.label && lhs.address == rhs.address && lhs.number == rhs.number && lhs.complement == rhs.complement && lhs.neighborhood == rhs.neighborhood && lhs.city == rhs.city && lhs.state == rhs.state && lhs.zipCode == rhs.zipCode } // MARK: - Stores func listPublicCategories() async throws -> ApiEnvelope<[PublicCategory]> { let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false) return try await sendEnvelope(req) } func listStores(lat: Double? = nil, lng: Double? = nil, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> { var items: [URLQueryItem] = [] if let lat, let lng { items.append(URLQueryItem(name: "lat", value: String(lat))) items.append(URLQueryItem(name: "lng", value: String(lng))) } if let category { items.append(URLQueryItem(name: "category", value: category)) } if let search { items.append(URLQueryItem(name: "search", value: search)) } let req = ApiRequest(path: "/api/app/stores", method: "GET", module: .app, requiresAuth: true, queryItems: items) return try await sendEnvelope(req) } func storeInfo(storeId: String) async throws -> ApiEnvelope { let req = ApiRequest(path: "/api/store/\(storeId)/info", method: "GET", module: .store, requiresAuth: true) return try await sendEnvelope(req) } func storeCatalog(storeId: String) async throws -> ApiEnvelope<[StoreCatalogCategory]> { let req = ApiRequest(path: "/api/store/\(storeId)/catalog", method: "GET", module: .store, requiresAuth: true) return try await sendEnvelope(req) } } // MARK: - DTOs struct EmptyResult: Decodable {} struct RegistrationResult: Decodable { let id: String? let name: String? let email: String? } struct LoginResult: Decodable { let token: String let customer: CustomerProfile? } struct CustomerProfile: Decodable { let id: String let name: String let email: String let phoneNumber: String? let profilePicture: String? let addressBook: [CustomerAddress]? enum CodingKeys: String, CodingKey { case id case name case email case phoneNumber case profilePicture case addressBook = "address_book" } } struct CustomerAddress: Decodable { let id: String? let label: String? let address: String? let number: String? let complement: String? let neighborhood: String? let city: String? let state: String? let zipCode: String? let latLong: [Double]? enum CodingKeys: String, CodingKey { case id case label case address case number case complement case neighborhood case city case state case zipCode case latLong = "lat_long" } } struct StoreSummary: Decodable { let id: String let name: String let logo: String? let cover: String? let category: String? let rating: Double? let deliveryTime: String? let deliveryFee: Double? let distance: Double? let isOpen: Bool? let statusLabel: String? } struct StoreInfoResult: Decodable { let isOpen: Bool? let statusLabel: String? let deliveryTime: String? let minOrder: Double? let address: StoreAddressInfo? let paymentMethods: StorePaymentMethodsInfo? enum CodingKeys: String, CodingKey { case isOpen case statusLabel case deliveryTime case minOrder case address case paymentMethods } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) isOpen = try? container.decode(Bool.self, forKey: .isOpen) statusLabel = try? container.decode(String.self, forKey: .statusLabel) deliveryTime = try? container.decode(String.self, forKey: .deliveryTime) minOrder = ApiService.decodeFlexibleDouble(from: container, keys: [.minOrder]) address = try? container.decode(StoreAddressInfo.self, forKey: .address) paymentMethods = try? container.decode(StorePaymentMethodsInfo.self, forKey: .paymentMethods) } } struct StoreAddressInfo: Decodable { let street: String? let number: String? let neighborhood: String? let city: String? let state: String? let latitude: Double? let longitude: Double? enum CodingKeys: String, CodingKey { case street case number case neighborhood case city case state case latitude case longitude } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) street = try? container.decode(String.self, forKey: .street) number = try? container.decode(String.self, forKey: .number) neighborhood = try? container.decode(String.self, forKey: .neighborhood) city = try? container.decode(String.self, forKey: .city) state = try? container.decode(String.self, forKey: .state) latitude = ApiService.decodeFlexibleDouble(from: container, keys: [.latitude]) longitude = ApiService.decodeFlexibleDouble(from: container, keys: [.longitude]) } } struct StorePaymentMethodsInfo: Decodable { let acceptPix: Bool? let acceptCash: Bool? let acceptCreditCard: Bool? let acceptDebitCard: Bool? } struct StoreCatalogCategory: Decodable { let id: String let name: String let products: [StoreCatalogProduct] enum CodingKeys: String, CodingKey { case id case name case products } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString name = (try? container.decode(String.self, forKey: .name)) ?? "Categoria" products = (try? container.decode([StoreCatalogProduct].self, forKey: .products)) ?? [] } } struct StoreCatalogProduct: Decodable, Identifiable { let id: String let name: String let description: String? let image: String? let price: Double? let originalPrice: Double? let addonGroups: [StoreAddonGroup] enum CodingKeys: String, CodingKey { case id case name case description case desc case image case cover case photo case price case originalPrice case oldPrice case addonGroups case addons } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString name = (try? container.decode(String.self, forKey: .name)) ?? "Produto" description = (try? container.decode(String.self, forKey: .description)) ?? (try? container.decode(String.self, forKey: .desc)) image = (try? container.decode(String.self, forKey: .image)) ?? (try? container.decode(String.self, forKey: .cover)) ?? (try? container.decode(String.self, forKey: .photo)) price = ApiService.decodeFlexibleDouble(from: container, keys: [.price]) originalPrice = ApiService.decodeFlexibleDouble(from: container, keys: [.originalPrice, .oldPrice]) addonGroups = (try? container.decode([StoreAddonGroup].self, forKey: .addonGroups)) ?? (try? container.decode([StoreAddonGroup].self, forKey: .addons)) ?? [] } } struct StoreAddonGroup: Decodable, Identifiable { let id: String let name: String let minSelectors: Int? let maxSelectors: Int? let items: [StoreAddonItem] enum CodingKeys: String, CodingKey { case id case name case minSelectors case maxSelectors case items } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString name = (try? container.decode(String.self, forKey: .name)) ?? "Adicionais" minSelectors = try? container.decode(Int.self, forKey: .minSelectors) maxSelectors = try? container.decode(Int.self, forKey: .maxSelectors) items = (try? container.decode([StoreAddonItem].self, forKey: .items)) ?? [] } } struct StoreAddonItem: Decodable, Identifiable { let id: String let name: String let price: Double? enum CodingKeys: String, CodingKey { case id case name case price } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString name = (try? container.decode(String.self, forKey: .name)) ?? "Item" price = ApiService.decodeFlexibleDouble(from: container, keys: [.price]) } } struct PublicCategory: Decodable { let id: String let name: String let icon: String? } struct CustomerProfileUpdatePayload: Encodable { let addressBook: [CustomerAddressPayload] enum CodingKeys: String, CodingKey { case addressBook = "address_book" } } struct CustomerAddressPayload: Encodable { let label: String? let address: String? let number: String? let complement: String? let neighborhood: String? let city: String? let state: String? let zipCode: String? let latLong: [Double]? enum CodingKeys: String, CodingKey { case label case address case number case complement case neighborhood case city case state case zipCode case latLong = "lat_long" } init(from address: CustomerAddress) { self.label = address.label self.address = address.address self.number = address.number self.complement = address.complement self.neighborhood = address.neighborhood self.city = address.city self.state = address.state self.zipCode = address.zipCode self.latLong = address.latLong } } struct CepLookupResult: Decodable { let zipCode: String? let street: String? let neighborhood: String? let city: String? let state: String? let complement: String? let latitude: Double? let longitude: Double? enum CodingKeys: String, CodingKey { case zipCode case cep case zip case normalized case raw case street case logradouro case address case neighborhood case bairro case district case city case cidade case localidade case state case estado case uf case complement case complemento case latitude case lat case longitude case lng } enum NormalizedKeys: String, CodingKey { case cep case logradouro case bairro case cidade case uf case latitude case longitude } enum RawKeys: String, CodingKey { case cep case address case district case city case state case lat case lng } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let normalizedContainer = try? container.nestedContainer(keyedBy: NormalizedKeys.self, forKey: .normalized) let rawContainer = try? container.nestedContainer(keyedBy: RawKeys.self, forKey: .raw) let directZip = CepLookupResult.decodeString(from: container, keys: [.zipCode, .cep, .zip]) let directStreet = CepLookupResult.decodeString(from: container, keys: [.street, .logradouro, .address]) let directNeighborhood = CepLookupResult.decodeString(from: container, keys: [.neighborhood, .bairro, .district]) let directCity = CepLookupResult.decodeString(from: container, keys: [.city, .cidade, .localidade]) let directState = CepLookupResult.decodeString(from: container, keys: [.state, .estado, .uf]) let directComplement = CepLookupResult.decodeString(from: container, keys: [.complement, .complemento]) let directLatitude = CepLookupResult.decodeFlexibleDouble(from: container, keys: [.latitude, .lat]) let directLongitude = CepLookupResult.decodeFlexibleDouble(from: container, keys: [.longitude, .lng]) let normalizedZip = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cep]) } let normalizedStreet = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.logradouro]) } let normalizedNeighborhood = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.bairro]) } let normalizedCity = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cidade]) } let normalizedState = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.uf]) } let normalizedLatitude = normalizedContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.latitude]) } let normalizedLongitude = normalizedContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.longitude]) } let rawZip = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cep]) } let rawStreet = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.address]) } let rawNeighborhood = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.district]) } let rawCity = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.city]) } let rawState = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.state]) } let rawLatitude = rawContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.lat]) } let rawLongitude = rawContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.lng]) } zipCode = directZip ?? normalizedZip ?? rawZip street = directStreet ?? normalizedStreet ?? rawStreet neighborhood = directNeighborhood ?? normalizedNeighborhood ?? rawNeighborhood city = directCity ?? normalizedCity ?? rawCity state = directState ?? normalizedState ?? rawState complement = directComplement latitude = directLatitude ?? normalizedLatitude ?? rawLatitude longitude = directLongitude ?? normalizedLongitude ?? rawLongitude } private static func decodeString(from container: KeyedDecodingContainer, keys: [K]) -> String? { for key in keys { if let value = try? container.decode(String.self, forKey: key) { return value } } return nil } private static func decodeFlexibleDouble(from container: KeyedDecodingContainer, keys: [K]) -> Double? { for key in keys { if let value = try? container.decode(Double.self, forKey: key) { return value } if let valueAsString = try? container.decode(String.self, forKey: key), let parsed = Double(valueAsString.replacingOccurrences(of: ",", with: ".")) { return parsed } } return nil } } private extension ApiService { static func decodeFlexibleDouble(from container: KeyedDecodingContainer, keys: [K]) -> Double? { for key in keys { if let value = try? container.decode(Double.self, forKey: key) { return value } if let asInt = try? container.decode(Int.self, forKey: key) { return Double(asInt) } if let asString = try? container.decode(String.self, forKey: key) { let normalized = asString .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: ",", with: ".") if let parsed = Double(normalized) { return parsed } } } return nil } }