import Foundation enum HTTPMethod: String { case get = "GET" case post = "POST" case put = "PUT" case patch = "PATCH" case delete = "DELETE" } struct NetworkResponse { let statusCode: Int let headers: [AnyHashable: Any] let data: Data } enum NetworkError: Error { case invalidURL case unauthorized case network(Error) case noResponse case server(statusCode: Int, data: Data?) } final class NetworkClient { static let shared = NetworkClient() private let session: URLSession private let refreshQueue = DispatchQueue(label: "org.yobble.network.refresh") private var isRefreshing = false private var refreshCompletions: [(Bool) -> Void] = [] init(session: URLSession = .shared) { self.session = session } func request( path: String, method: HTTPMethod = .get, query: [String: String?]? = nil, headers: [String: String] = [:], body: Data? = nil, contentType: String? = nil, requiresAuth: Bool = false, callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void ) { performRequest( path: path, method: method, query: query, headers: headers, body: body, contentType: contentType, requiresAuth: requiresAuth, callbackQueue: callbackQueue, allowRefreshRetry: true, isRetry: false, completion: completion ) } private func performRequest( path: String, method: HTTPMethod, query: [String: String?]?, headers: [String: String], body: Data?, contentType: String?, requiresAuth: Bool, callbackQueue: DispatchQueue, allowRefreshRetry: Bool, isRetry: Bool, completion: @escaping (Result) -> Void ) { guard let url = buildURL(path: path, query: query) else { callbackQueue.async { completion(.failure(.invalidURL)) } return } var request = URLRequest(url: url) request.httpMethod = method.rawValue request.httpBody = body var allHeaders: [String: String] = [ "User-Agent": AppConfig.USER_AGENT, "Accept": "application/json" ] if let contentType = contentType ?? (body != nil ? "application/json" : nil), headers["Content-Type"] == nil { allHeaders["Content-Type"] = contentType } headers.forEach { allHeaders[$0.key] = $0.value } if requiresAuth { guard let tokenInfo = currentTokenInfo() else { callbackQueue.async { completion(.failure(.unauthorized)) } return } allHeaders["Authorization"] = "Bearer \(tokenInfo.access)" } for (key, value) in allHeaders { request.setValue(value, forHTTPHeaderField: key) } let task = session.dataTask(with: request) { [weak self] data, response, error in guard let self else { return } if let error = error { callbackQueue.async { completion(.failure(.network(error))) } return } guard let httpResponse = response as? HTTPURLResponse else { callbackQueue.async { completion(.failure(.noResponse)) } return } let responseData = data ?? Data() if (200...299).contains(httpResponse.statusCode) { let payload = NetworkResponse( statusCode: httpResponse.statusCode, headers: httpResponse.allHeaderFields, data: responseData ) callbackQueue.async { completion(.success(payload)) } return } if httpResponse.statusCode == 401, requiresAuth, allowRefreshRetry, !isRetry { self.refreshAccessToken { success in if success { self.performRequest( path: path, method: method, query: query, headers: headers, body: body, contentType: contentType, requiresAuth: requiresAuth, callbackQueue: callbackQueue, allowRefreshRetry: false, isRetry: true, completion: completion ) } else { callbackQueue.async { completion(.failure(.unauthorized)) } } } return } callbackQueue.async { completion(.failure(.server(statusCode: httpResponse.statusCode, data: responseData))) } } task.resume() } private func buildURL(path: String, query: [String: String?]?) -> URL? { guard let baseURL = URL(string: AppConfig.API_SERVER) else { return nil } var cleanedPath = path if cleanedPath.hasPrefix("/") { cleanedPath.removeFirst() } let url = cleanedPath.isEmpty ? baseURL : baseURL.appendingPathComponent(cleanedPath) guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } if let query { let items = query.compactMap { key, value -> URLQueryItem? in guard let value else { return nil } return URLQueryItem(name: key, value: value) } if !items.isEmpty { components.queryItems = items } } return components.url } private func currentTokenInfo() -> (login: String, access: String, refresh: String)? { guard let login = UserDefaults.standard.string(forKey: "currentUser"), let access = KeychainService.shared.get(forKey: "access_token", service: login), let refresh = KeychainService.shared.get(forKey: "refresh_token", service: login) else { return nil } return (login, access, refresh) } private func refreshAccessToken(completion: @escaping (Bool) -> Void) { refreshQueue.async { if self.isRefreshing { self.refreshCompletions.append(completion) return } guard let tokenInfo = self.currentTokenInfo() else { completion(false) return } self.isRefreshing = true self.refreshCompletions.append(completion) let payload = RefreshRequest(access_token: tokenInfo.access, refresh_token: tokenInfo.refresh) guard let body = try? JSONEncoder().encode(payload) else { self.completeRefresh(success: false) return } self.performRequest( path: "/v1/auth/token/refresh", method: .post, query: nil, headers: [:], body: body, contentType: "application/json", requiresAuth: false, callbackQueue: self.refreshQueue, allowRefreshRetry: false, isRetry: false ) { result in switch result { case .success(let response): do { let decoder = JSONDecoder() let apiResponse = try decoder.decode(APIResponse.self, from: response.data) guard apiResponse.status == "fine" else { self.completeRefresh(success: false) return } let data = apiResponse.data KeychainService.shared.save(data.access_token, forKey: "access_token", service: tokenInfo.login) KeychainService.shared.save(data.refresh_token, forKey: "refresh_token", service: tokenInfo.login) if let userId = data.user_id { KeychainService.shared.save(userId, forKey: "userId", service: tokenInfo.login) } self.completeRefresh(success: true) } catch { self.completeRefresh(success: false) } case .failure: self.completeRefresh(success: false) } } } } private func completeRefresh(success: Bool) { let completions = refreshCompletions refreshCompletions.removeAll() isRefreshing = false completions.forEach { $0(success) } } } private struct RefreshRequest: Encodable { let access_token: String let refresh_token: String }