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,252 +7,137 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class AuthService {
|
final class AuthService {
|
||||||
|
|
||||||
func autoLogin(completion: @escaping (Bool, String?) -> Void) {
|
func autoLogin(completion: @escaping (Bool, String?) -> Void) {
|
||||||
// 1️⃣ Проверяем наличие текущего пользователя
|
|
||||||
if let currentUser = UserDefaults.standard.string(forKey: "currentUser"),
|
if let currentUser = UserDefaults.standard.string(forKey: "currentUser"),
|
||||||
let _ = KeychainService.shared.get(forKey: "access_token", service: currentUser),
|
let _ = KeychainService.shared.get(forKey: "access_token", service: currentUser),
|
||||||
let _ = KeychainService.shared.get(forKey: "refresh_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)
|
completion(true, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2️⃣ Текущий пользователь не найден или токены отсутствуют
|
if AppConfig.DEBUG { print("AutoLogin: текущий пользователь не найден или токены отсутствуют. Пробуем найти другого пользователя...") }
|
||||||
if AppConfig.DEBUG{ print("AutoLogin: текущий пользователь не найден или токены отсутствуют. Пробуем найти другого пользователя...")}
|
|
||||||
|
|
||||||
let allUsers = KeychainService.shared.getAllServices()
|
let allUsers = KeychainService.shared.getAllServices()
|
||||||
|
|
||||||
for user in allUsers {
|
for user in allUsers {
|
||||||
let hasAccessToken = KeychainService.shared.get(forKey: "access_token", service: user) != nil
|
let hasAccessToken = KeychainService.shared.get(forKey: "access_token", service: user) != nil
|
||||||
let hasRefreshToken = KeychainService.shared.get(forKey: "refresh_token", service: user) != nil
|
let hasRefreshToken = KeychainService.shared.get(forKey: "refresh_token", service: user) != nil
|
||||||
|
|
||||||
if hasAccessToken && hasRefreshToken {
|
if hasAccessToken && hasRefreshToken {
|
||||||
// Нашли пользователя с токенами — назначаем как currentUser
|
|
||||||
UserDefaults.standard.set(user, forKey: "currentUser")
|
UserDefaults.standard.set(user, forKey: "currentUser")
|
||||||
if AppConfig.DEBUG{ print("AutoLogin: переключились на пользователя \(user)")}
|
if AppConfig.DEBUG { print("AutoLogin: переключились на пользователя \(user)") }
|
||||||
completion(true, nil)
|
completion(true, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3️⃣ Если никто не найден
|
|
||||||
// completion(false, "Не найден авторизованный пользователь. Пожалуйста, войдите снова.")
|
|
||||||
completion(false, nil)
|
completion(false, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func login(username: String, password: String, completion: @escaping (Bool, String?) -> Void) {
|
func login(username: String, password: String, completion: @escaping (Bool, String?) -> Void) {
|
||||||
let url = URL(string: "\(AppConfig.API_SERVER)/v1/auth/login")!
|
let payload = LoginRequest(login: username, password: password)
|
||||||
var request = URLRequest(url: url)
|
guard let body = try? JSONEncoder().encode(payload) else {
|
||||||
request.httpMethod = "POST"
|
completion(false, NSLocalizedString("Не удалось сериализовать данные запроса.", comment: ""))
|
||||||
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: ""))
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
NetworkClient.shared.request(
|
||||||
DispatchQueue.main.async {
|
path: "/v1/auth/login",
|
||||||
if let error = error {
|
method: .post,
|
||||||
let errorMessage = String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), error.localizedDescription)
|
body: body,
|
||||||
completion(false, errorMessage)
|
requiresAuth: false
|
||||||
return
|
) { result in
|
||||||
}
|
switch result {
|
||||||
|
case .success(let response):
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
let loginResponse = try decoder.decode(APIResponse<LoginPayload>.self, from: data).data
|
let apiResponse = try decoder.decode(APIResponse<TokenPairPayload>.self, from: response.data)
|
||||||
|
|
||||||
// Сохраняем токены в Keychain
|
guard apiResponse.status == "fine" else {
|
||||||
KeychainService.shared.save(loginResponse.access_token, forKey: "access_token", service: username)
|
let message = apiResponse.detail ?? NSLocalizedString("Неизвестная ошибка", comment: "")
|
||||||
KeychainService.shared.save(loginResponse.refresh_token, forKey: "refresh_token", service: username)
|
completion(false, message)
|
||||||
KeychainService.shared.save(loginResponse.user_id, forKey: "userId", service: username)
|
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")
|
UserDefaults.standard.set(username, forKey: "currentUser")
|
||||||
|
|
||||||
completion(true, nil)
|
completion(true, nil)
|
||||||
} catch {
|
} catch {
|
||||||
completion(false, NSLocalizedString("Не удалось обработать ответ сервера.", comment: ""))
|
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) {
|
func register(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) {
|
||||||
let url = URL(string: "\(AppConfig.API_SERVER)/v1/auth/register")!
|
let payload = RegisterRequest(login: username, password: password, invite: invite)
|
||||||
var request = URLRequest(url: url)
|
guard let body = try? JSONEncoder().encode(payload) else {
|
||||||
request.httpMethod = "POST"
|
completion(false, NSLocalizedString("Не удалось сериализовать данные запроса.", comment: ""))
|
||||||
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: ""))
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
NetworkClient.shared.request(
|
||||||
DispatchQueue.main.async {
|
path: "/v1/auth/register",
|
||||||
if let error = error {
|
method: .post,
|
||||||
let errorMessage = String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), error.localizedDescription)
|
body: body,
|
||||||
completion(false, errorMessage)
|
requiresAuth: false
|
||||||
return
|
) { result in
|
||||||
}
|
switch result {
|
||||||
|
case .success(let response):
|
||||||
|
do {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
let apiResponse = try decoder.decode(APIResponse<RegisterPayload>.self, from: response.data)
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard apiResponse.status == "fine" else {
|
||||||
completion(false, NSLocalizedString("Некорректный ответ от сервера.", comment: ""))
|
let message = apiResponse.detail ?? NSLocalizedString("Неизвестная ошибка", comment: "")
|
||||||
return
|
completion(false, message)
|
||||||
}
|
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("Регистрация успешна. Пытаемся сразу войти...")}
|
|
||||||
|
|
||||||
// Сразу логинимся
|
|
||||||
self.login(username: username, password: password) { loginSuccess, loginMessage in
|
|
||||||
if loginSuccess {
|
|
||||||
completion(true, "Регистрация и вход выполнены успешно.")
|
|
||||||
} else {
|
|
||||||
// Регистрация успешна, но логин не удался — покажем сообщение
|
|
||||||
completion(false, loginMessage ?? NSLocalizedString("Регистрация выполнена, но вход не удался.", comment: ""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
completion(false, NSLocalizedString("Не удалось обработать ответ сервера.", comment: ""))
|
|
||||||
}
|
}
|
||||||
} 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("Регистрация успешна. Пытаемся сразу войти...") }
|
||||||
if AppConfig.DEBUG{ print("Raw JSON:", jsonString)}
|
|
||||||
}
|
|
||||||
if AppConfig.DEBUG{ print("message:", message)}
|
|
||||||
|
|
||||||
if httpResponse.statusCode == 400 {
|
self.login(username: username, password: password) { loginSuccess, loginMessage in
|
||||||
if message.contains("Invalid invitation code") {
|
if loginSuccess {
|
||||||
completion(false, NSLocalizedString("Неверный код приглашения.", comment: ""))
|
completion(true, 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: ""))
|
|
||||||
} else {
|
} else {
|
||||||
let errorMessage = String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(httpResponse.statusCode)")
|
completion(false, loginMessage ?? NSLocalizedString("Регистрация выполнена, но вход не удался.", comment: ""))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} 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) {
|
func logoutCurrentUser(completion: @escaping (Bool, String?) -> Void) {
|
||||||
guard let currentUser = UserDefaults.standard.string(forKey: "currentUser") else {
|
guard let currentUser = UserDefaults.standard.string(forKey: "currentUser") else {
|
||||||
completion(false, "Не найден текущий пользователь.")
|
completion(false, "Не найден текущий пользователь.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удаляем токены текущего пользователя
|
|
||||||
KeychainService.shared.delete(forKey: "access_token", service: currentUser)
|
KeychainService.shared.delete(forKey: "access_token", service: currentUser)
|
||||||
KeychainService.shared.delete(forKey: "refresh_token", service: currentUser)
|
KeychainService.shared.delete(forKey: "refresh_token", service: currentUser)
|
||||||
KeychainService.shared.delete(forKey: "userId", service: currentUser)
|
KeychainService.shared.delete(forKey: "userId", service: currentUser)
|
||||||
|
|
||||||
// Сбрасываем текущего пользователя
|
|
||||||
UserDefaults.standard.removeObject(forKey: "currentUser")
|
UserDefaults.standard.removeObject(forKey: "currentUser")
|
||||||
|
|
||||||
// Пробуем переключиться на другого пользователя
|
|
||||||
let allUsers = KeychainService.shared.getAllServices()
|
let allUsers = KeychainService.shared.getAllServices()
|
||||||
for user in allUsers {
|
for user in allUsers {
|
||||||
let hasAccessToken = KeychainService.shared.get(forKey: "access_token", service: user) != nil
|
let hasAccessToken = KeychainService.shared.get(forKey: "access_token", service: user) != nil
|
||||||
@ -260,43 +145,107 @@ class AuthService {
|
|||||||
|
|
||||||
if hasAccessToken && hasRefreshToken {
|
if hasAccessToken && hasRefreshToken {
|
||||||
UserDefaults.standard.set(user, forKey: "currentUser")
|
UserDefaults.standard.set(user, forKey: "currentUser")
|
||||||
if AppConfig.DEBUG{ print("Logout: переключились на пользователя \(user)")}
|
if AppConfig.DEBUG { print("Logout: переключились на пользователя \(user)") }
|
||||||
completion(true, nil)
|
completion(true, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если пользователей больше нет
|
|
||||||
// completion(false, "Нет доступных пользователей. Пожалуйста, войдите снова.")
|
|
||||||
completion(false, nil)
|
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 {
|
private struct LoginRequest: Encodable {
|
||||||
let status: String
|
let login: String
|
||||||
let data: T
|
let password: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LoginPayload: Decodable {
|
private struct RegisterRequest: Encodable {
|
||||||
let access_token: String
|
let login: String
|
||||||
let refresh_token: String
|
let password: String
|
||||||
let user_id: String
|
let invite: 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?
|
|
||||||
}
|
}
|
||||||
|
|||||||
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" : "Регистрация"
|
"comment" : "Регистрация"
|
||||||
@ -447,6 +444,9 @@
|
|||||||
},
|
},
|
||||||
"Регистрация запрещена." : {
|
"Регистрация запрещена." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Регистрация и вход выполнены успешно." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Редактировать профиль" : {
|
"Редактировать профиль" : {
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user