add network manager
This commit is contained in:
		
							parent
							
								
									24ca5643eb
								
							
						
					
					
						commit
						200b0172eb
					
				
							
								
								
									
										27
									
								
								yobble/Network/APIModels.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								yobble/Network/APIModels.swift
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,27 @@
 | 
			
		||||
import Foundation
 | 
			
		||||
 | 
			
		||||
struct APIResponse<T: Decodable>: Decodable {
 | 
			
		||||
    let status: String
 | 
			
		||||
    let data: T
 | 
			
		||||
    let detail: String?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct TokenPairPayload: Decodable {
 | 
			
		||||
    let access_token: String
 | 
			
		||||
    let refresh_token: String
 | 
			
		||||
    let user_id: String?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct RegisterPayload: Decodable {
 | 
			
		||||
    let message: String
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct ErrorPayload: Decodable {
 | 
			
		||||
    let message: String?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct ErrorResponse: Decodable {
 | 
			
		||||
    let status: String?
 | 
			
		||||
    let data: ErrorPayload?
 | 
			
		||||
    let detail: String?
 | 
			
		||||
}
 | 
			
		||||
@ -7,296 +7,245 @@
 | 
			
		||||
 | 
			
		||||
import Foundation
 | 
			
		||||
 | 
			
		||||
class AuthService {
 | 
			
		||||
    
 | 
			
		||||
final class AuthService {
 | 
			
		||||
 | 
			
		||||
    func autoLogin(completion: @escaping (Bool, String?) -> Void) {
 | 
			
		||||
        // 1️⃣ Проверяем наличие текущего пользователя
 | 
			
		||||
        if let currentUser = UserDefaults.standard.string(forKey: "currentUser"),
 | 
			
		||||
           let _ = KeychainService.shared.get(forKey: "access_token", service: currentUser),
 | 
			
		||||
           let _ = KeychainService.shared.get(forKey: "refresh_token", service: currentUser) {
 | 
			
		||||
            if AppConfig.DEBUG{ print("AutoLogin: найден текущий пользователь — \(currentUser)")}
 | 
			
		||||
            if AppConfig.DEBUG { print("AutoLogin: найден текущий пользователь — \(currentUser)") }
 | 
			
		||||
            completion(true, nil)
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // 2️⃣ Текущий пользователь не найден или токены отсутствуют
 | 
			
		||||
        if AppConfig.DEBUG{ print("AutoLogin: текущий пользователь не найден или токены отсутствуют. Пробуем найти другого пользователя...")}
 | 
			
		||||
        
 | 
			
		||||
        if AppConfig.DEBUG { print("AutoLogin: текущий пользователь не найден или токены отсутствуют. Пробуем найти другого пользователя...") }
 | 
			
		||||
 | 
			
		||||
        let allUsers = KeychainService.shared.getAllServices()
 | 
			
		||||
        
 | 
			
		||||
        for user in allUsers {
 | 
			
		||||
            let hasAccessToken = KeychainService.shared.get(forKey: "access_token", service: user) != nil
 | 
			
		||||
            let hasRefreshToken = KeychainService.shared.get(forKey: "refresh_token", service: user) != nil
 | 
			
		||||
            
 | 
			
		||||
 | 
			
		||||
            if hasAccessToken && hasRefreshToken {
 | 
			
		||||
                // Нашли пользователя с токенами — назначаем как currentUser
 | 
			
		||||
                UserDefaults.standard.set(user, forKey: "currentUser")
 | 
			
		||||
                if AppConfig.DEBUG{ print("AutoLogin: переключились на пользователя \(user)")}
 | 
			
		||||
                if AppConfig.DEBUG { print("AutoLogin: переключились на пользователя \(user)") }
 | 
			
		||||
                completion(true, nil)
 | 
			
		||||
                return
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // 3️⃣ Если никто не найден
 | 
			
		||||
//        completion(false, "Не найден авторизованный пользователь. Пожалуйста, войдите снова.")
 | 
			
		||||
 | 
			
		||||
        completion(false, nil)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
    func login(username: String, password: String, completion: @escaping (Bool, String?) -> Void) {
 | 
			
		||||
        let url = URL(string: "\(AppConfig.API_SERVER)/v1/auth/login")!
 | 
			
		||||
        var request = URLRequest(url: url)
 | 
			
		||||
        request.httpMethod = "POST"
 | 
			
		||||
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 | 
			
		||||
        request.setValue("\(AppConfig.USER_AGENT)", forHTTPHeaderField: "User-Agent")
 | 
			
		||||
 | 
			
		||||
        let payload: [String: String] = [
 | 
			
		||||
            "login": username,
 | 
			
		||||
            "password": password
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            let jsonData = try JSONEncoder().encode(payload)
 | 
			
		||||
            request.httpBody = jsonData
 | 
			
		||||
        } catch {
 | 
			
		||||
            DispatchQueue.main.async {
 | 
			
		||||
                completion(false, NSLocalizedString("Не удалось сериализовать данные запроса.", comment: ""))
 | 
			
		||||
            }
 | 
			
		||||
        let payload = LoginRequest(login: username, password: password)
 | 
			
		||||
        guard let body = try? JSONEncoder().encode(payload) else {
 | 
			
		||||
            completion(false, NSLocalizedString("Не удалось сериализовать данные запроса.", comment: ""))
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
 | 
			
		||||
            DispatchQueue.main.async {
 | 
			
		||||
                if let error = error {
 | 
			
		||||
                    let errorMessage = String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), error.localizedDescription)
 | 
			
		||||
                    completion(false, errorMessage)
 | 
			
		||||
                    return
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                guard let httpResponse = response as? HTTPURLResponse else {
 | 
			
		||||
                    completion(false, NSLocalizedString("Некорректный ответ от сервера.", comment: ""))
 | 
			
		||||
                    return
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                guard (200...299).contains(httpResponse.statusCode) else {
 | 
			
		||||
                    if httpResponse.statusCode == 401{
 | 
			
		||||
                        completion(false, NSLocalizedString("Неверный логин или пароль.", comment: ""))
 | 
			
		||||
                    } else if httpResponse.statusCode == 502{
 | 
			
		||||
                        completion(false, NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: ""))
 | 
			
		||||
                    } else if httpResponse.statusCode == 429 {
 | 
			
		||||
                        completion(false, NSLocalizedString("Слишком много запросов.", comment: ""))
 | 
			
		||||
                    } else {
 | 
			
		||||
                        let errorMessage = String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(httpResponse.statusCode)")
 | 
			
		||||
                        completion(false, errorMessage)
 | 
			
		||||
                    }
 | 
			
		||||
                    return
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                guard let data = data else {
 | 
			
		||||
                    completion(false, NSLocalizedString("Пустой ответ от сервера.", comment: ""))
 | 
			
		||||
                    return
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
        NetworkClient.shared.request(
 | 
			
		||||
            path: "/v1/auth/login",
 | 
			
		||||
            method: .post,
 | 
			
		||||
            body: body,
 | 
			
		||||
            requiresAuth: false
 | 
			
		||||
        ) { result in
 | 
			
		||||
            switch result {
 | 
			
		||||
            case .success(let response):
 | 
			
		||||
                do {
 | 
			
		||||
                    let decoder = JSONDecoder()
 | 
			
		||||
                    let loginResponse = try decoder.decode(APIResponse<LoginPayload>.self, from: data).data
 | 
			
		||||
                    
 | 
			
		||||
                    // Сохраняем токены в Keychain
 | 
			
		||||
                    KeychainService.shared.save(loginResponse.access_token, forKey: "access_token", service: username)
 | 
			
		||||
                    KeychainService.shared.save(loginResponse.refresh_token, forKey: "refresh_token", service: username)
 | 
			
		||||
                    KeychainService.shared.save(loginResponse.user_id, forKey: "userId", service: username)
 | 
			
		||||
                    let apiResponse = try decoder.decode(APIResponse<TokenPairPayload>.self, from: response.data)
 | 
			
		||||
 | 
			
		||||
                    guard apiResponse.status == "fine" else {
 | 
			
		||||
                        let message = apiResponse.detail ?? NSLocalizedString("Неизвестная ошибка", comment: "")
 | 
			
		||||
                        completion(false, message)
 | 
			
		||||
                        return
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    let tokens = apiResponse.data
 | 
			
		||||
                    KeychainService.shared.save(tokens.access_token, forKey: "access_token", service: username)
 | 
			
		||||
                    KeychainService.shared.save(tokens.refresh_token, forKey: "refresh_token", service: username)
 | 
			
		||||
                    if let userId = tokens.user_id {
 | 
			
		||||
                        KeychainService.shared.save(userId, forKey: "userId", service: username)
 | 
			
		||||
                    }
 | 
			
		||||
                    UserDefaults.standard.set(username, forKey: "currentUser")
 | 
			
		||||
                    
 | 
			
		||||
 | 
			
		||||
                    completion(true, nil)
 | 
			
		||||
                } catch {
 | 
			
		||||
                    completion(false, NSLocalizedString("Не удалось обработать ответ сервера.", comment: ""))
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
            case .failure(let error):
 | 
			
		||||
                let message = self.loginErrorMessage(for: error)
 | 
			
		||||
                completion(false, message)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        task.resume()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func register(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) {
 | 
			
		||||
        let url = URL(string: "\(AppConfig.API_SERVER)/v1/auth/register")!
 | 
			
		||||
        var request = URLRequest(url: url)
 | 
			
		||||
        request.httpMethod = "POST"
 | 
			
		||||
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 | 
			
		||||
        request.setValue(AppConfig.USER_AGENT, forHTTPHeaderField: "User-Agent")
 | 
			
		||||
 | 
			
		||||
        let payload: [String: Any] = [
 | 
			
		||||
            "login": username,
 | 
			
		||||
            "password": password,
 | 
			
		||||
            "invite": invite ?? NSNull()
 | 
			
		||||
        ]
 | 
			
		||||
        
 | 
			
		||||
        do {
 | 
			
		||||
            let jsonData = try JSONSerialization.data(withJSONObject: payload)
 | 
			
		||||
            request.httpBody = jsonData
 | 
			
		||||
        } catch {
 | 
			
		||||
            DispatchQueue.main.async {
 | 
			
		||||
                completion(false, NSLocalizedString("Не удалось сериализовать данные запроса.", comment: ""))
 | 
			
		||||
            }
 | 
			
		||||
        let payload = RegisterRequest(login: username, password: password, invite: invite)
 | 
			
		||||
        guard let body = try? JSONEncoder().encode(payload) else {
 | 
			
		||||
            completion(false, NSLocalizedString("Не удалось сериализовать данные запроса.", comment: ""))
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
 | 
			
		||||
            DispatchQueue.main.async {
 | 
			
		||||
                if let error = error {
 | 
			
		||||
                    let errorMessage = String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), error.localizedDescription)
 | 
			
		||||
                    completion(false, errorMessage)
 | 
			
		||||
                    return
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                guard let httpResponse = response as? HTTPURLResponse else {
 | 
			
		||||
                    completion(false, NSLocalizedString("Некорректный ответ от сервера.", comment: ""))
 | 
			
		||||
                    return
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                guard let data = data else {
 | 
			
		||||
                    completion(false, NSLocalizedString("Пустой ответ от сервера.", comment: ""))
 | 
			
		||||
                    return
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                let decoder = JSONDecoder()
 | 
			
		||||
                
 | 
			
		||||
                if (200...299).contains(httpResponse.statusCode) {
 | 
			
		||||
                    do {
 | 
			
		||||
                        let _ = try decoder.decode(APIResponse<RegisterPayload>.self, from: data)
 | 
			
		||||
                        if AppConfig.DEBUG{ print("Регистрация успешна. Пытаемся сразу войти...")}
 | 
			
		||||
        NetworkClient.shared.request(
 | 
			
		||||
            path: "/v1/auth/register",
 | 
			
		||||
            method: .post,
 | 
			
		||||
            body: body,
 | 
			
		||||
            requiresAuth: false
 | 
			
		||||
        ) { result in
 | 
			
		||||
            switch result {
 | 
			
		||||
            case .success(let response):
 | 
			
		||||
                do {
 | 
			
		||||
                    let decoder = JSONDecoder()
 | 
			
		||||
                    let apiResponse = try decoder.decode(APIResponse<RegisterPayload>.self, from: response.data)
 | 
			
		||||
 | 
			
		||||
                        // Сразу логинимся
 | 
			
		||||
                        self.login(username: username, password: password) { loginSuccess, loginMessage in
 | 
			
		||||
                            if loginSuccess {
 | 
			
		||||
                                completion(true, "Регистрация и вход выполнены успешно.")
 | 
			
		||||
                            } else {
 | 
			
		||||
                                // Регистрация успешна, но логин не удался — покажем сообщение
 | 
			
		||||
                                completion(false, loginMessage ?? NSLocalizedString("Регистрация выполнена, но вход не удался.", comment: ""))
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    } catch {
 | 
			
		||||
                        completion(false, NSLocalizedString("Не удалось обработать ответ сервера.", comment: ""))
 | 
			
		||||
                    guard apiResponse.status == "fine" else {
 | 
			
		||||
                        let message = apiResponse.detail ?? NSLocalizedString("Неизвестная ошибка", comment: "")
 | 
			
		||||
                        completion(false, message)
 | 
			
		||||
                        return
 | 
			
		||||
                    }
 | 
			
		||||
                } else {
 | 
			
		||||
                    // Ошибка сервера — пробуем распарсить message
 | 
			
		||||
                    if let errorResponseMessage = try? decoder.decode(ErrorResponse.self, from: data),
 | 
			
		||||
                       let message = errorResponseMessage.data?.message {
 | 
			
		||||
                        
 | 
			
		||||
                        if let jsonString = String(data: data, encoding: .utf8) {
 | 
			
		||||
                            if AppConfig.DEBUG{ print("Raw JSON:", jsonString)}
 | 
			
		||||
                        }
 | 
			
		||||
                        if AppConfig.DEBUG{ print("message:", message)}
 | 
			
		||||
                        
 | 
			
		||||
                        if httpResponse.statusCode == 400 {
 | 
			
		||||
                            if message.contains("Invalid invitation code") {
 | 
			
		||||
                                completion(false, NSLocalizedString("Неверный код приглашения.", comment: ""))
 | 
			
		||||
                            } else if message.contains("This invitation is not active") {
 | 
			
		||||
                                completion(false, NSLocalizedString("Приглашение не активно.", comment: ""))
 | 
			
		||||
                            } else if message.contains("This invitation has reached its usage limit") {
 | 
			
		||||
                                completion(false, NSLocalizedString("Приглашение достигло лимита использования.", comment: ""))
 | 
			
		||||
                            } else if message.contains("This invitation has expired") {
 | 
			
		||||
                                completion(false, NSLocalizedString("Приглашение истекло.", comment: ""))
 | 
			
		||||
                            } else if message.contains("Login already registered") {
 | 
			
		||||
                                completion(false, NSLocalizedString("Логин уже занят.", comment: ""))
 | 
			
		||||
                            } else {
 | 
			
		||||
                                completion(false, message)
 | 
			
		||||
                            }
 | 
			
		||||
                        } else if httpResponse.statusCode == 403 {
 | 
			
		||||
                            if message.contains("Registration is currently disabled") {
 | 
			
		||||
                                completion(false, NSLocalizedString("Регистрация временно недоступна.", comment: ""))
 | 
			
		||||
                            } else {
 | 
			
		||||
                                completion(false, message)
 | 
			
		||||
                            }
 | 
			
		||||
                        } else if httpResponse.statusCode == 429 {
 | 
			
		||||
                            completion(false, NSLocalizedString("Слишком много запросов.", comment: ""))
 | 
			
		||||
                        } else if httpResponse.statusCode == 502{
 | 
			
		||||
                            completion(false, NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: ""))
 | 
			
		||||
 | 
			
		||||
                    if AppConfig.DEBUG { print("Регистрация успешна. Пытаемся сразу войти...") }
 | 
			
		||||
 | 
			
		||||
                    self.login(username: username, password: password) { loginSuccess, loginMessage in
 | 
			
		||||
                        if loginSuccess {
 | 
			
		||||
                            completion(true, NSLocalizedString("Регистрация и вход выполнены успешно.", comment: ""))
 | 
			
		||||
                        } else {
 | 
			
		||||
                            let errorMessage = String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(httpResponse.statusCode)")
 | 
			
		||||
                            completion(false, errorMessage)
 | 
			
		||||
                        }
 | 
			
		||||
                    } else {
 | 
			
		||||
                        // Не удалось распарсить JSON — fallback
 | 
			
		||||
                        if httpResponse.statusCode == 400 {
 | 
			
		||||
                            completion(false, NSLocalizedString("Неверный запрос (400).", comment: ""))
 | 
			
		||||
                        } else if httpResponse.statusCode == 403 {
 | 
			
		||||
                            completion(false, NSLocalizedString("Регистрация запрещена.", comment: ""))
 | 
			
		||||
                        } else if httpResponse.statusCode == 429 {
 | 
			
		||||
                            completion(false, NSLocalizedString("Слишком много запросов.", comment: ""))
 | 
			
		||||
                        } else if httpResponse.statusCode == 502{
 | 
			
		||||
                            completion(false, NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: ""))
 | 
			
		||||
                        } else {
 | 
			
		||||
                            let errorMessage = String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(httpResponse.statusCode)")
 | 
			
		||||
                            completion(false, errorMessage)
 | 
			
		||||
                            completion(false, loginMessage ?? NSLocalizedString("Регистрация выполнена, но вход не удался.", comment: ""))
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                } catch {
 | 
			
		||||
                    completion(false, NSLocalizedString("Не удалось обработать ответ сервера.", comment: ""))
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
            case .failure(let error):
 | 
			
		||||
                let message = self.registerErrorMessage(for: error)
 | 
			
		||||
                completion(false, message)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        task.resume()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    func logoutCurrentUser(completion: @escaping (Bool, String?) -> Void) {
 | 
			
		||||
        guard let currentUser = UserDefaults.standard.string(forKey: "currentUser") else {
 | 
			
		||||
            completion(false, "Не найден текущий пользователь.")
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // Удаляем токены текущего пользователя
 | 
			
		||||
 | 
			
		||||
        KeychainService.shared.delete(forKey: "access_token", service: currentUser)
 | 
			
		||||
        KeychainService.shared.delete(forKey: "refresh_token", service: currentUser)
 | 
			
		||||
        KeychainService.shared.delete(forKey: "userId", service: currentUser)
 | 
			
		||||
        
 | 
			
		||||
        // Сбрасываем текущего пользователя
 | 
			
		||||
 | 
			
		||||
        UserDefaults.standard.removeObject(forKey: "currentUser")
 | 
			
		||||
        
 | 
			
		||||
        // Пробуем переключиться на другого пользователя
 | 
			
		||||
 | 
			
		||||
        let allUsers = KeychainService.shared.getAllServices()
 | 
			
		||||
        for user in allUsers {
 | 
			
		||||
            let hasAccessToken = KeychainService.shared.get(forKey: "access_token", service: user) != nil
 | 
			
		||||
            let hasRefreshToken = KeychainService.shared.get(forKey: "refresh_token", service: user) != nil
 | 
			
		||||
            
 | 
			
		||||
 | 
			
		||||
            if hasAccessToken && hasRefreshToken {
 | 
			
		||||
                UserDefaults.standard.set(user, forKey: "currentUser")
 | 
			
		||||
                if AppConfig.DEBUG{ print("Logout: переключились на пользователя \(user)")}
 | 
			
		||||
                if AppConfig.DEBUG { print("Logout: переключились на пользователя \(user)") }
 | 
			
		||||
                completion(true, nil)
 | 
			
		||||
                return
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Если пользователей больше нет
 | 
			
		||||
//        completion(false, "Нет доступных пользователей. Пожалуйста, войдите снова.")
 | 
			
		||||
        completion(false, nil)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func loginErrorMessage(for error: NetworkError) -> String {
 | 
			
		||||
        switch error {
 | 
			
		||||
        case .network(let err):
 | 
			
		||||
            return String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), err.localizedDescription)
 | 
			
		||||
        case .server(let statusCode, _):
 | 
			
		||||
            switch statusCode {
 | 
			
		||||
            case 401:
 | 
			
		||||
                return NSLocalizedString("Неверный логин или пароль.", comment: "")
 | 
			
		||||
            case 429:
 | 
			
		||||
                return NSLocalizedString("Слишком много запросов.", comment: "")
 | 
			
		||||
            case 502:
 | 
			
		||||
                return NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: "")
 | 
			
		||||
            default:
 | 
			
		||||
                return String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(statusCode)")
 | 
			
		||||
            }
 | 
			
		||||
        case .unauthorized:
 | 
			
		||||
            return NSLocalizedString("Неверный логин или пароль.", comment: "")
 | 
			
		||||
        case .invalidURL, .noResponse:
 | 
			
		||||
            return NSLocalizedString("Некорректный ответ от сервера.", comment: "")
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func registerErrorMessage(for error: NetworkError) -> String {
 | 
			
		||||
        switch error {
 | 
			
		||||
        case .network(let err):
 | 
			
		||||
            return String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), err.localizedDescription)
 | 
			
		||||
        case .server(let statusCode, let data):
 | 
			
		||||
            if let data,
 | 
			
		||||
               let response = try? JSONDecoder().decode(ErrorResponse.self, from: data),
 | 
			
		||||
               let message = response.data?.message {
 | 
			
		||||
                return mappedRegistrationMessage(for: message, statusCode: statusCode)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            switch statusCode {
 | 
			
		||||
            case 400:
 | 
			
		||||
                return NSLocalizedString("Неверный запрос (400).", comment: "")
 | 
			
		||||
            case 403:
 | 
			
		||||
                return NSLocalizedString("Регистрация запрещена.", comment: "")
 | 
			
		||||
            case 429:
 | 
			
		||||
                return NSLocalizedString("Слишком много запросов.", comment: "")
 | 
			
		||||
            case 502:
 | 
			
		||||
                return NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: "")
 | 
			
		||||
            default:
 | 
			
		||||
                return String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(statusCode)")
 | 
			
		||||
            }
 | 
			
		||||
        case .unauthorized:
 | 
			
		||||
            return NSLocalizedString("Регистрация запрещена.", comment: "")
 | 
			
		||||
        case .invalidURL, .noResponse:
 | 
			
		||||
            return NSLocalizedString("Некорректный ответ от сервера.", comment: "")
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func mappedRegistrationMessage(for message: String, statusCode: Int) -> String {
 | 
			
		||||
        if statusCode == 400 {
 | 
			
		||||
            if message.contains("Invalid invitation code") {
 | 
			
		||||
                return NSLocalizedString("Неверный код приглашения.", comment: "")
 | 
			
		||||
            } else if message.contains("This invitation is not active") {
 | 
			
		||||
                return NSLocalizedString("Приглашение не активно.", comment: "")
 | 
			
		||||
            } else if message.contains("This invitation has reached its usage limit") {
 | 
			
		||||
                return NSLocalizedString("Приглашение достигло лимита использования.", comment: "")
 | 
			
		||||
            } else if message.contains("This invitation has expired") {
 | 
			
		||||
                return NSLocalizedString("Приглашение истекло.", comment: "")
 | 
			
		||||
            } else if message.contains("Login already registered") {
 | 
			
		||||
                return NSLocalizedString("Логин уже занят.", comment: "")
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if statusCode == 403 {
 | 
			
		||||
            if message.contains("Registration is currently disabled") {
 | 
			
		||||
                return NSLocalizedString("Регистрация временно недоступна.", comment: "")
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if statusCode == 429 {
 | 
			
		||||
            return NSLocalizedString("Слишком много запросов.", comment: "")
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if statusCode == 502 {
 | 
			
		||||
            return NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: "")
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return message
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct APIResponse<T: Decodable>: Decodable {
 | 
			
		||||
    let status: String
 | 
			
		||||
    let data: T
 | 
			
		||||
private struct LoginRequest: Encodable {
 | 
			
		||||
    let login: String
 | 
			
		||||
    let password: String
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct LoginPayload: Decodable {
 | 
			
		||||
    let access_token: String
 | 
			
		||||
    let refresh_token: String
 | 
			
		||||
    let user_id: String
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct TokenRefreshPayload: Decodable {
 | 
			
		||||
    let access_token: String
 | 
			
		||||
    let token_type: String
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct RegisterPayload: Decodable {
 | 
			
		||||
    let message: String
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct ErrorPayload: Decodable {
 | 
			
		||||
    let message: String?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct ErrorResponse: Decodable {
 | 
			
		||||
    let status: String?
 | 
			
		||||
    let data: ErrorPayload?
 | 
			
		||||
private struct RegisterRequest: Encodable {
 | 
			
		||||
    let login: String
 | 
			
		||||
    let password: String
 | 
			
		||||
    let invite: String?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										291
									
								
								yobble/Network/NetworkClient.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										291
									
								
								yobble/Network/NetworkClient.swift
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,291 @@
 | 
			
		||||
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)
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        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
 | 
			
		||||
}
 | 
			
		||||
@ -432,9 +432,6 @@
 | 
			
		||||
    },
 | 
			
		||||
    "Публичная информация" : {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    "Пустой ответ от сервера." : {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    "Регистрация" : {
 | 
			
		||||
      "comment" : "Регистрация"
 | 
			
		||||
@ -447,6 +444,9 @@
 | 
			
		||||
    },
 | 
			
		||||
    "Регистрация запрещена." : {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    "Регистрация и вход выполнены успешно." : {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    "Редактировать профиль" : {
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user