import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #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)" } } } struct ApiRequest { 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 { let url = try buildURL(path: request.path, query: request.queryItems) var urlRequest = URLRequest(url: url) urlRequest.httpMethod = request.method urlRequest.httpBody = request.body urlRequest.setValue("application/json", forHTTPHeaderField: "Accept") urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") if let token = ApiConfig.token(for: request.module) { urlRequest.setValue(token, forHTTPHeaderField: "Atomenta-Token") } if request.requiresAuth, let jwt = tokenStore.jwt { urlRequest.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") } 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 } private 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 } 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)) } } private 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 } } private 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) } private func serverMessage(from data: Data) -> String? { if let envelope = try? JSONDecoder().decode(ApiEnvelope.self, from: data) { return envelope.message } if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { if let message = object["message"] as? String { return message } if let code = object["code"] as? String { return "Erro: \(code)" } } return String(data: data, encoding: .utf8) } private 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 } }