292 lines
9.3 KiB
Swift
292 lines
9.3 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)
|
|
}
|
|
|
|
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
|
|
}
|