import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif #if canImport(LCEssentials) && os(iOS) import LCEssentials #endif enum NetworkError: Error, LocalizedError { case invalidURL case invalidResponse case httpError(Int, String?) case unauthorized(String?) case decodeError(String?) case rateLimited(Int?) case cancelled case timedOut case transportError(String) var errorDescription: String? { switch self { case .invalidURL: return "URL invalida" case .invalidResponse: return "Resposta invalida do servidor" case .httpError(let code, let message): return message ?? "Erro HTTP (\(code))" case .unauthorized(let message): return message ?? "Sessao expirada. Faca login novamente." case .decodeError(let payload): if let payload, payload.isEmpty == false { return "Erro ao interpretar dados: \(payload)" } return "Erro ao interpretar dados" case .rateLimited(let retryAfter): if let retryAfter { return "Muitas requisicoes. Tente novamente em \(retryAfter)s." } return "Muitas requisicoes. Tente novamente." case .cancelled: return "Requisicao cancelada" case .timedOut: return "O servidor demorou demais para responder. Tente novamente." case .transportError(let message): return "Erro de rede: \(message)" } } } private struct ApiErrorDescriptor { let code: String? let message: String? } struct ApiRequest: Sendable { let path: String let method: String let module: ApiModule let requiresAuth: Bool let queryItems: [URLQueryItem] let body: Data? /// Overrides `ApiConfig.baseURL` (Atomenta) for this single request — used /// to reach the PediFoods BFF (`ApiConfig.pediFoodsBFFURL`) instead. let baseURLOverride: URL? /// Sent as `Authorization: Bearer ` instead of the customer JWT. /// Used for guest-session-authenticated public locator calls, which must /// never touch `TokenStore`'s customer session. let customBearerToken: String? init(path: String, method: String = "GET", module: ApiModule = .none, requiresAuth: Bool = true, queryItems: [URLQueryItem] = [], body: Data? = nil, baseURLOverride: URL? = nil, customBearerToken: String? = nil) { self.path = path self.method = method self.module = module self.requiresAuth = requiresAuth self.queryItems = queryItems self.body = body self.baseURLOverride = baseURLOverride self.customBearerToken = customBearerToken } } final class ApiClient: @unchecked Sendable { private let session: URLSession private let tokenStore: TokenStore private let maxAttempts = 3 private let baseBackoffNanoseconds: UInt64 = 300_000_000 init(session: URLSession = .shared, tokenStore: TokenStore = DefaultTokenStore()) { self.session = session self.tokenStore = tokenStore } func send(_ request: ApiRequest) async throws -> T { // Hard client-side cutoff independent of whatever timeout logic // lives inside the underlying transport (LCEssentials or plain // URLSession). If that transport ever stalls without ever // resolving — no response, no error, nothing — the UI must still // get an answer so it can stop showing "nothing happened." try await withTimeout(seconds: 20) { [self] in #if canImport(LCEssentials) && os(iOS) try await sendWithLCEssentials(request) #else try await sendWithURLSession(request) #endif } } private func withTimeout(seconds: Double, operation: @escaping @Sendable () async throws -> T) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await operation() } group.addTask { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) throw NetworkError.timedOut } guard let result = try await group.next() else { throw NetworkError.timedOut } group.cancelAll() return result } } } private extension ApiClient { #if canImport(LCEssentials) && os(iOS) func sendWithLCEssentials(_ request: ApiRequest) async throws -> T { let urlString = try buildURL(path: request.path, query: request.queryItems, baseURL: request.baseURLOverride ?? ApiConfig.baseURL).absoluteString let method = request.method let headers = buildHeaders(for: request) let params = request.body var attempt = 1 while attempt <= maxAttempts { do { let responseString = try await Self.performLCERequest( url: urlString, params: params, method: method, headers: headers ) guard let data = responseString.data(using: .utf8) else { throw NetworkError.decodeError("Resposta nao UTF-8") } if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let errorFlag = object["error"] as? Bool, errorFlag { let message = object["message"] as? String ?? object["msg"] as? String throw NetworkError.httpError(200, message ?? "Erro no servidor") } do { return try JSONDecoder().decode(T.self, from: data) } catch { throw NetworkError.decodeError(sanitizedBody(data)) } } catch { let mapped = mapError(error) guard shouldRetry(mapped), attempt < maxAttempts else { throw mapped } try await Task.sleep(nanoseconds: backoff(for: attempt, error: mapped)) attempt += 1 } } throw NetworkError.invalidResponse } @MainActor static func performLCERequest( url: String, params: Data?, method: String, headers: [String: String] ) async throws -> String { let httpMethod = toHTTPMethod(method) return try await API.shared.request( url: url, params: params, method: httpMethod, headers: headers, jsonEncoding: true, debug: true ) } static func toHTTPMethod(_ method: String) -> httpMethod { switch method.uppercased() { case "POST": return .post case "PUT": return .put case "DELETE": return .delete case "PATCH": return .patch default: return .get } } func mapError(_ error: Error) -> NetworkError { if let network = error as? NetworkError { return network } if let decoding = error as? DecodingError { return .decodeError(String(describing: decoding)) } let nsError = error as NSError let apiMessage = serverMessage(from: nsError) let payload = serverPayload(from: nsError) if isSessionExpiredPayload(code: payload?.code, message: payload?.message ?? apiMessage) { return .unauthorized(payload?.message ?? apiMessage) } if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled { return .cancelled } printError(title: "httpReqError", msg: error.localizedDescription) switch nsError.code { case 401, 403: return .unauthorized(apiMessage) case 429: return .rateLimited(nil) case 400...599: return .httpError(nsError.code, apiMessage) default: break } if nsError.domain == NSURLErrorDomain { return .transportError(nsError.localizedDescription) } return .transportError(nsError.localizedDescription) } #endif func sendWithURLSession(_ request: ApiRequest) async throws -> T { let url = try buildURL(path: request.path, query: request.queryItems, baseURL: request.baseURLOverride ?? ApiConfig.baseURL) var urlRequest = URLRequest(url: url) urlRequest.httpMethod = request.method urlRequest.httpBody = request.body for (key, value) in buildHeaders(for: request) { urlRequest.setValue(value, forHTTPHeaderField: key) } var attempt = 1 while attempt <= maxAttempts { do { return try await perform(urlRequest, as: T.self) } catch is CancellationError { throw NetworkError.cancelled } catch let error as NetworkError { guard shouldRetry(error), attempt < maxAttempts else { throw error } try await Task.sleep(nanoseconds: backoff(for: attempt, error: error)) attempt += 1 } catch { let wrapped = NetworkError.transportError(error.localizedDescription) guard attempt < maxAttempts else { throw wrapped } try await Task.sleep(nanoseconds: backoff(for: attempt, error: wrapped)) attempt += 1 } } throw NetworkError.invalidResponse } func perform(_ request: URLRequest, as type: T.Type) async throws -> T { let (data, response): (Data, URLResponse) do { (data, response) = try await session.data(for: request) } catch { if let urlError = error as? URLError, urlError.code == .cancelled { throw NetworkError.cancelled } throw NetworkError.transportError(error.localizedDescription) } guard let http = response as? HTTPURLResponse else { throw NetworkError.invalidResponse } let payload = serverPayload(from: data) if isSessionExpiredPayload(code: payload?.code, message: payload?.message) { throw NetworkError.unauthorized(payload?.message) } if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let errorFlag = object["error"] as? Bool, errorFlag { let message = object["message"] as? String ?? object["msg"] as? String throw NetworkError.httpError(http.statusCode, message ?? "Erro no servidor") } if http.statusCode == 429 { let retryAfter = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "") throw NetworkError.rateLimited(retryAfter) } if http.statusCode == 401 || http.statusCode == 403 { throw NetworkError.unauthorized(serverMessage(from: data)) } if !(200...299).contains(http.statusCode) { throw NetworkError.httpError(http.statusCode, serverMessage(from: data)) } do { return try JSONDecoder().decode(type, from: data) } catch { throw NetworkError.decodeError(sanitizedBody(data)) } } func buildHeaders(for request: ApiRequest) -> [String: String] { var headers: [String: String] = [ "Accept": "application/json", "Content-Type": "application/json" ] if let token = ApiConfig.token(for: request.module) { headers["Atomenta-Token"] = token } if let customBearerToken = request.customBearerToken { headers["Authorization"] = "Bearer \(customBearerToken)" } else if request.requiresAuth, let jwt = tokenStore.jwt { headers["Authorization"] = "Bearer \(jwt)" } return headers } func shouldRetry(_ error: NetworkError) -> Bool { switch error { case .rateLimited, .transportError: return true case .httpError(let statusCode, _): return statusCode >= 500 case .invalidURL, .invalidResponse, .decodeError, .unauthorized, .cancelled, .timedOut: return false } } func backoff(for attempt: Int, error: NetworkError) -> UInt64 { if case .rateLimited(let retryAfter) = error, let retryAfter { return UInt64(retryAfter) * 1_000_000_000 } let multiplier = UInt64(max(1, attempt)) return min(baseBackoffNanoseconds * multiplier, 2_000_000_000) } func serverMessage(from data: Data) -> String? { let payload = serverPayload(from: data) if let message = payload?.message, message.isEmpty == false { return message } if let code = payload?.code, code.isEmpty == false { return "Erro: \(code)" } // fallback if error true is present but without a classic structure if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let errorFlag = object["error"] as? Bool, errorFlag { if let msg = object["message"] as? String ?? object["msg"] as? String { return msg } } return sanitizedBody(data) } func serverPayload(from data: Data) -> ApiErrorDescriptor? { if let envelope = try? JSONDecoder().decode(ApiEnvelope.self, from: data) { return ApiErrorDescriptor(code: envelope.code, message: envelope.message) } if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { let errorFlag = object["error"] as? Bool ?? false let code = object["code"] as? String let message = object["message"] as? String ?? object["msg"] as? String ?? object["error_description"] as? String if errorFlag || code != nil || message != nil { return ApiErrorDescriptor(code: code, message: message) } } return nil } func isSessionExpiredPayload(code: String?, message: String?) -> Bool { let normalizedCode = (code ?? "").lowercased() let normalizedMessage = (message ?? "").lowercased() if normalizedCode.contains("auth") || normalizedCode.contains("token") || normalizedCode.contains("unauthorized") { return true } if normalizedMessage.contains("token") && (normalizedMessage.contains("expir") || normalizedMessage.contains("invalid") || normalizedMessage.contains("sess")) { return true } return false } #if canImport(LCEssentials) && os(iOS) func serverMessage(from error: NSError) -> String? { for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data", "message"] { if let value = error.userInfo[key] as? String, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let data = value.data(using: .utf8), let parsed = serverMessage(from: data), !parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines) if t.hasPrefix("{") { continue } if let safe = sanitizedMessage(t) { return safe } } let t = value.trimmingCharacters(in: .whitespacesAndNewlines) if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe } } else if let valueData = error.userInfo[key] as? Data { if let parsed = serverMessage(from: valueData) { let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines) if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe } } } } for (_, value) in error.userInfo { if let str = value as? String, str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"), let data = str.data(using: .utf8), let parsed = serverMessage(from: data) { let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines) if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe } } } if let description = error.userInfo[NSLocalizedDescriptionKey] as? String, !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !description.lowercased().contains("nsurlerrordomain"), let safe = sanitizedMessage(description) { return safe } return nil } func serverPayload(from error: NSError) -> ApiErrorDescriptor? { for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data"] { if let value = error.userInfo[key] as? String, let data = value.data(using: .utf8), let payload = serverPayload(from: data) { return payload } else if let valueData = error.userInfo[key] as? Data, let payload = serverPayload(from: valueData) { return payload } } for (_, value) in error.userInfo { if let str = value as? String, str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"), let data = str.data(using: .utf8), let payload = serverPayload(from: data) { return payload } } return nil } #endif func sanitizedBody(_ data: Data) -> String? { guard let raw = String(data: data, encoding: .utf8) else { return nil } return sanitizedMessage(raw) } func sanitizedMessage(_ raw: String) -> String? { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.isEmpty == false else { return nil } // Drop data URLs (base64 images) if trimmed.lowercased().hasPrefix("data:image") { return "Erro ao processar imagem." } // Drop fields containing base64, if trimmed.contains("base64,") { return "Resposta do servidor inválida." } // Truncate long strings (raw JSON bodies, etc.) if trimmed.count > 300 { return String(trimmed.prefix(300)) + "…" } return trimmed } func buildURL(path: String, query: [URLQueryItem], baseURL: URL) throws -> URL { guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { throw NetworkError.invalidURL } components.path = components.path.appending(path) if !query.isEmpty { components.queryItems = query } guard let url = components.url else { throw NetworkError.invalidURL } return url } }