import Foundation import Alamofire import Combine import UIKit /// Enhanced API Client with advanced features class APIClient { // MARK: - Properties // < static let shared = APIClient() private let session: Session private let decoder: JSONDecoder private let encoder: JSONEncoder private let interceptor: AuthorizationInterceptor private let logger = NetworkLogger.self // Cache for images private let imageCache = NSCache() // Request tracking for cancellation private var activeRequests: [String: DataRequest] = [:] private let requestLock = NSLock() // MARK: - Configuration struct Configuration { var timeout: TimeInterval = 30 var cachePolicy: URLRequest.CachePolicy = .reloadIgnoringLocalCacheData var retryLimit: Int = 3 var logLevel: NetworkLogger.LogLevel = .debug var enableCaching: Bool = true var maxConcurrentRequests: Int = 5 } private(set) var configuration = Configuration() // MARK: - Initialization init() { // Custom decoder with flexible date handling self.decoder = Self.createJSONDecoder() self.encoder = Self.createJSONEncoder() // Configure session let sessionConfiguration = URLSessionConfiguration.default sessionConfiguration.timeoutIntervalForRequest = configuration.timeout sessionConfiguration.timeoutIntervalForResource = configuration.timeout * 2 sessionConfiguration.requestCachePolicy = configuration.cachePolicy sessionConfiguration.httpMaximumConnectionsPerHost = configuration.maxConcurrentRequests // Create interceptor self.interceptor = AuthorizationInterceptor() // Create session with event monitors let eventMonitor = APIEventMonitor() self.session = Session( configuration: sessionConfiguration, interceptor: interceptor, eventMonitors: [eventMonitor] ) // Configure logger logger.logLevel = configuration.logLevel // Configure image cache imageCache.countLimit = 100 imageCache.totalCostLimit = 50 * 1024 * 1024 // 50MB } // MARK: - Configuration Methods func configure(_ config: Configuration) { self.configuration = config logger.logLevel = config.logLevel } func setAuthToken(_ token: String?) { interceptor.setAccessToken(token) } // MARK: - JSON Coders private static func createJSONDecoder() -> JSONDecoder { let decoder = JSONDecoder() // Flexible date decoding strategy decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let dateStr = try container.decode(String.self) // Handle microseconds format (6 decimal places) if let date = parseISO8601WithMicroseconds(dateStr) { return date } // Try multiple date formats let formatters = [ ISO8601DateFormatter.internetDateTimeWithFractionalSeconds, ISO8601DateFormatter.internetDateTime, DateFormatter.iso8601Full, DateFormatter.iso8601 ] for formatter in formatters { if let date = (formatter as? ISO8601DateFormatter)?.date(from: dateStr) ?? (formatter as? DateFormatter)?.date(from: dateStr) { return date } } throw DecodingError.dataCorruptedError( in: container, debugDescription: "Cannot decode date string: \(dateStr)" ) } return decoder } // Add this helper function private static func parseISO8601WithMicroseconds(_ dateString: String) -> Date? { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(secondsFromGMT: 0) if let date = formatter.date(from: dateString) { return date } // Try with timezone if present formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ" return formatter.date(from: dateString) } private static func createJSONEncoder() -> JSONEncoder { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 // encoder.keyEncodingStrategy = .convertToSnakeCase return encoder } // MARK: - Request Methods /// Generic request with Encodable parameters and Decodable response @discardableResult func request( _ url: URLConvertible, method: HTTPMethod = .get, parameters: Parameters? = nil, headers: HTTPHeaders? = nil, requestId: String? = nil ) async throws -> Response { let startTime = Date() let id = requestId ?? UUID().uuidString // Log JSON payload before sending /* if let parameters = parameters { do { let jsonData = try encoder.encode(parameters) if let jsonString = String(data: jsonData, encoding: .utf8) { fluxlog("📤 Request JSON Body:") print(jsonString) } } catch { fluxlog("⚠️ Failed to encode parameters for logging: \(error)") } } */ let request = session.request( url, method: method, parameters: parameters, encoder: JSONParameterEncoder(encoder: encoder), headers: headers, interceptor: interceptor ) .validate() // Rest of the method remains the same trackRequest(request, id: id) defer { removeRequest(id: id) } if let urlRequest = request.request { logger.logRequest(urlRequest) // Log the actual request body if available if let httpBody = urlRequest.httpBody, let bodyString = String(data: httpBody, encoding: .utf8) { fluxlog("📤 Actual Request Body:") print(bodyString) } } let response = await request.serializingDecodable(Response.self, decoder: decoder).response let duration = Date().timeIntervalSince(startTime) switch response.result { case .success(let value): if let httpResponse = response.response { logger.logResponse(httpResponse, data: response.data, duration: duration) } return value case .failure(let error): logger.logError(error, request: response.request, data: response.data, duration: duration) throw mapError(error, response: response.response, data: response.data) } } /// Request with dictionary parameters @discardableResult func request( _ url: URLConvertible, method: HTTPMethod = .get, parameters: [String: Any]? = nil, encoding: ParameterEncoding = JSONEncoding.default, headers: HTTPHeaders? = nil, requestId: String? = nil ) async throws -> Response { let startTime = Date() let id = requestId ?? UUID().uuidString let request = session.request( url, method: method, parameters: parameters, encoding: encoding, headers: headers, interceptor: interceptor ) .validate() trackRequest(request, id: id) defer { removeRequest(id: id) } if let urlRequest = request.request { logger.logRequest(urlRequest) } let response = await request.serializingDecodable(Response.self, decoder: decoder).response let duration = Date().timeIntervalSince(startTime) switch response.result { case .success(let value): if let httpResponse = response.response { logger.logResponse(httpResponse, data: response.data, duration: duration) } return value case .failure(let error): logger.logError(error, request: response.request, data: response.data, duration: duration) throw mapError(error, response: response.response, data: response.data) } } /// Request with no response body expected func requestVoid( _ url: URLConvertible, method: HTTPMethod = .get, parameters: Parameters? = nil, headers: HTTPHeaders? = nil, requestId: String? = nil ) async throws { let startTime = Date() let id = requestId ?? UUID().uuidString let request = session.request( url, method: method, parameters: parameters, encoder: JSONParameterEncoder(encoder: encoder), headers: headers, interceptor: interceptor ) .validate() trackRequest(request, id: id) defer { removeRequest(id: id) } if let urlRequest = request.request { logger.logRequest(urlRequest) } // Use Empty.self with emptyResponseCodes including 200 to handle // servers that return 200 OK with an empty body (zero-length data) let response = await request .serializingDecodable( Empty.self, emptyResponseCodes: [200, 201, 204, 205] ) .response let duration = Date().timeIntervalSince(startTime) switch response.result { case .success: if let httpResponse = response.response { logger.logResponse(httpResponse, data: response.data, duration: duration) } case .failure(let error): logger.logError(error, request: response.request, data: response.data, duration: duration) throw mapError(error, response: response.response, data: response.data) } } /// Download image with caching func downloadImage( from url: URLConvertible, cacheKey: String? = nil, headers: HTTPHeaders? = nil, requestId: String? = nil ) async throws -> UIImage { // Check cache first let key = cacheKey ?? (try? url.asURL().absoluteString) ?? UUID().uuidString if configuration.enableCaching, let cachedImage = imageCache.object(forKey: key as NSString) { return cachedImage } let startTime = Date() let id = requestId ?? UUID().uuidString let request = session.request( url, method: .get, headers: headers, interceptor: interceptor ) .validate() trackRequest(request, id: id) defer { removeRequest(id: id) } if let urlRequest = request.request { logger.logRequest(urlRequest) } let response = await request.serializingData().response let duration = Date().timeIntervalSince(startTime) switch response.result { case .success(let data): if let httpResponse = response.response { logger.logResponse(httpResponse, data: data, duration: duration) } guard let image = UIImage(data: data) else { throw APIError.decodingFailed(NSError(domain: "ImageDecoding", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to create UIImage from data"])) } // Cache the image if configuration.enableCaching { imageCache.setObject(image, forKey: key as NSString, cost: data.count) } return image case .failure(let error): logger.logError(error, request: response.request, data: response.data, duration: duration) throw mapError(error, response: response.response, data: response.data) } } /// Download file to disk func downloadFile( from url: URLConvertible, to destination: URL, headers: HTTPHeaders? = nil, progressHandler: ((Double) -> Void)? = nil ) async throws { let request = session.download( url, method: .get, headers: headers, interceptor: interceptor, to: { _, _ in (destination, [.removePreviousFile, .createIntermediateDirectories]) } ) .validate() if let progressHandler = progressHandler { request.downloadProgress { progress in progressHandler(progress.fractionCompleted) } } let response = await request.serializingDownload(using: .url).response switch response.result { case .success: break case .failure(let error): throw mapError(error, response: response.response, data: nil) } } /// Upload multipart form data func upload( _ url: URLConvertible, multipartFormData: @escaping (MultipartFormData) -> Void, headers: HTTPHeaders? = nil, progressHandler: ((Double) -> Void)? = nil ) async throws -> Response { let startTime = Date() let request = session.upload( multipartFormData: multipartFormData, to: url, headers: headers, interceptor: interceptor ) .validate() if let progressHandler = progressHandler { request.uploadProgress { progress in progressHandler(progress.fractionCompleted) } } let response = await request.serializingDecodable(Response.self, decoder: decoder).response let duration = Date().timeIntervalSince(startTime) switch response.result { case .success(let value): if let httpResponse = response.response { logger.logResponse(httpResponse, data: response.data, duration: duration) } return value case .failure(let error): logger.logError(error, request: response.request, data: response.data, duration: duration) throw mapError(error, response: response.response, data: response.data) } } /// Stream a local file as raw bytes in the request body (no multipart). /// Matches the backend's `POST /upload?filename=…` endpoint which reads /// directly from `HttpServletRequest.getInputStream()`. /// /// - Parameters: /// - url: The endpoint URL (include query params if needed). /// - fileURL: Local file to stream. /// - headers: Optional extra headers. `Content-Type` defaults to /// `application/octet-stream` if not provided. /// - progressHandler: Called on the main queue with 0…1 fraction. func uploadFile( to url: URLConvertible, fileURL: URL, headers: HTTPHeaders? = nil, progressHandler: ((Double) -> Void)? = nil ) async throws { let startTime = Date() var finalHeaders = headers ?? HTTPHeaders() if finalHeaders["Content-Type"] == nil { finalHeaders.add(.contentType("application/octet-stream")) } let request = session.upload( fileURL, to: url, method: .post, headers: finalHeaders, interceptor: interceptor ) .validate() if let progressHandler = progressHandler { request.uploadProgress { progress in DispatchQueue.main.async { progressHandler(progress.fractionCompleted) } } } if let urlRequest = request.request { logger.logRequest(urlRequest) } let response = await request .serializingDecodable(Empty.self, emptyResponseCodes: [200, 201, 204, 205]) .response let duration = Date().timeIntervalSince(startTime) switch response.result { case .success: if let httpResponse = response.response { logger.logResponse(httpResponse, data: response.data, duration: duration) } case .failure(let error): logger.logError(error, request: response.request, data: response.data, duration: duration) throw mapError(error, response: response.response, data: response.data) } } // MARK: - Request Management func cancelRequest(id: String) { requestLock.lock() defer { requestLock.unlock() } activeRequests[id]?.cancel() activeRequests.removeValue(forKey: id) } func cancelAllRequests() { requestLock.lock() defer { requestLock.unlock() } activeRequests.values.forEach { $0.cancel() } activeRequests.removeAll() } private func trackRequest(_ request: DataRequest, id: String) { requestLock.lock() defer { requestLock.unlock() } activeRequests[id] = request } private func removeRequest(id: String) { requestLock.lock() defer { requestLock.unlock() } activeRequests.removeValue(forKey: id) } // MARK: - Cache Management func clearImageCache() { imageCache.removeAllObjects() } func clearImageCache(forKey key: String) { imageCache.removeObject(forKey: key as NSString) } // MARK: - Error Mapping private func mapError(_ error: AFError, response: HTTPURLResponse?, data: Data?) -> Error { if error.isSessionTaskError { if let underlyingError = error.underlyingError as? URLError { switch underlyingError.code { case .timedOut: return APIError.timeout case .cancelled: return APIError.cancelled default: return APIError.networkError(underlyingError) } } } // Handle serialization errors first — e.g. empty body on 200 OK if error.isResponseSerializationError { // If the server responded with a 2xx, don't treat this as a real error if let statusCode = response?.statusCode, (200..<300).contains(statusCode) { return APIError.cancelled // treat as no-op, caller discards this } return APIError.decodingFailed(error) } if let statusCode = response?.statusCode { switch statusCode { case 200..<300: // 2xx with unexpected shape — not a real error return APIError.cancelled case 401: return APIError.unauthorized case 403: return APIError.forbidden case 404: return APIError.notFound case 500...599: return APIError.serverError(statusCode: statusCode) default: return ServerError(statusCode: statusCode, data: data, response: response) } } return APIError.unexpectedResponse } } // MARK: - Event Monitor private class APIEventMonitor: EventMonitor { func requestDidResume(_ request: Request) { // Track metrics if needed } func request(_ request: Request, didCreateTask task: URLSessionTask) { // Track metrics if needed } } // MARK: - Date Formatter Extensions private extension ISO8601DateFormatter { static let internetDateTime: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] return formatter }() static let internetDateTimeWithFractionalSeconds: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter }() } private extension DateFormatter { static let iso8601: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(secondsFromGMT: 0) return formatter }() static let iso8601Full: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" formatter.locale = Locale(identifier: "en_US_POSIX") return formatter }() }