ios_app_v2/yobble/Network/NetworkClient.swift
2025-10-21 02:39:48 +03:00

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
}