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 { let currentProfile = try await profile() guard currentProfile.error == false, let customer = currentProfile.result else { throw NetworkError.invalidResponse } var addressBook = (customer.addressBook ?? []).map(CustomerAddressPayload.init(from:)) addressBook.insert(CustomerAddressPayload(from: address), at: 0) let payload = CustomerProfileUpdatePayload(addressBook: addressBook) let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body) return try await sendEnvelope(req) } 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) } // 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) } } // 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 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 } }