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 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 .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? init(path: String, method: String = "GET", module: ApiModule = .none, requiresAuth: Bool = true, queryItems: [URLQueryItem] = [], body: Data? = nil) { self.path = path self.method = method self.module = module self.requiresAuth = requiresAuth self.queryItems = queryItems self.body = body } } final class ApiClient { 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 { // NOTE: // /api/customer/login is strict about body fields (email/phoneNumber/otp). // On iOS, routing this endpoint through URLSession ensures JSON body arrives as-is. if request.path == "/api/customer/login" { if let body = request.body, let bodyText = String(data: body, encoding: .utf8) { print("[ApiClient] /api/customer/login body: \(bodyText)") } return try await sendWithURLSession(request) } #if canImport(LCEssentials) && os(iOS) return try await sendWithLCEssentials(request) #else return try await sendWithURLSession(request) #endif } } 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).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") } do { return try JSONDecoder().decode(T.self, from: data) } catch { throw NetworkError.decodeError(String(data: data, encoding: .utf8)) } } 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 default: return .get } } func mapError(_ error: Error) -> NetworkError { printError(title: "httpReqError", msg: error.localizedDescription) 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) } 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) 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 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 { 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 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(String(data: data, encoding: .utf8)) } } 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 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: 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)" } return String(data: data, encoding: .utf8) } 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 code = object["code"] as? String let message = object["message"] as? String if 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? { if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String, !reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let data = reason.data(using: .utf8), let parsed = serverMessage(from: data), !parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return parsed } return reason } if let description = error.userInfo[NSLocalizedDescriptionKey] as? String, !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !description.lowercased().contains("nsurlerrordomain") { return description } return nil } func serverPayload(from error: NSError) -> ApiErrorDescriptor? { if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String, let data = reason.data(using: .utf8), let payload = serverPayload(from: data) { return payload } return nil } #endif func buildURL(path: String, query: [URLQueryItem]) throws -> URL { guard var components = URLComponents(url: ApiConfig.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 } }