This commit is contained in:
Daniel Arantes Loverde
2026-07-07 15:11:31 -03:00
parent 8b9ebdcb44
commit e51c99973f
293 changed files with 457 additions and 4919 deletions

View File

@@ -0,0 +1,488 @@
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?
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: @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<T: Decodable & Sendable>(_ 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<T: Sendable>(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<T: Decodable>(_ 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")
}
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<T: Decodable>(_ 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 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<T: Decodable>(_ 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 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<EmptyResult>.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]) 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
}
}