294 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			294 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
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<NetworkResponse, NetworkError>) -> 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<NetworkResponse, NetworkError>) -> 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<TokenPairPayload>.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)
 | 
						|
                        }
 | 
						|
 | 
						|
                        NotificationCenter.default.post(name: .accessTokenDidChange, object: nil)
 | 
						|
 | 
						|
                        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
 | 
						|
}
 |