176 lines
6.8 KiB
Swift
176 lines
6.8 KiB
Swift
//
|
||
// APIClient.swift
|
||
// Snail
|
||
//
|
||
|
||
import Foundation
|
||
|
||
nonisolated enum APIConfiguration {
|
||
/// Адрес бэкенда ulitka. В симуляторе localhost указывает на Mac, где запущен сервер;
|
||
/// на устройстве нужен адрес Mac в локальной сети.
|
||
#if targetEnvironment(simulator)
|
||
private static let host = "http://localhost:3000"
|
||
#else
|
||
private static let host = "https://madsky.ru"
|
||
#endif
|
||
|
||
static let baseURL: URL = {
|
||
guard let url = URL(string: "\(host)/api") else {
|
||
preconditionFailure("Некорректный адрес бэкенда")
|
||
}
|
||
return url
|
||
}()
|
||
}
|
||
|
||
nonisolated enum APIError: LocalizedError, Equatable {
|
||
case unauthorized
|
||
case server(status: Int, code: String?, message: String?)
|
||
case network(URLError)
|
||
case invalidResponse
|
||
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case .unauthorized:
|
||
return String(localized: "Сессия истекла. Войдите снова.")
|
||
case .server(_, let code, let message):
|
||
return Self.localizedMessage(forCode: code) ?? message ?? String(localized: "Что-то пошло не так. Попробуйте ещё раз.")
|
||
case .network(let error):
|
||
switch error.code {
|
||
case .notConnectedToInternet, .networkConnectionLost:
|
||
return String(localized: "Нет подключения к интернету.")
|
||
case .timedOut:
|
||
return String(localized: "Сервер не отвечает. Попробуйте позже.")
|
||
default:
|
||
return String(localized: "Не удалось связаться с сервером.")
|
||
}
|
||
case .invalidResponse:
|
||
return String(localized: "Сервер вернул неожиданный ответ.")
|
||
}
|
||
}
|
||
|
||
/// Коды ошибок better-auth, которые стоит показать пользователю понятным текстом.
|
||
private static func localizedMessage(forCode code: String?) -> String? {
|
||
switch code {
|
||
case "INVALID_EMAIL":
|
||
return String(localized: "Проверьте адрес электронной почты.")
|
||
case "INVALID_OTP":
|
||
return String(localized: "Неверный код. Проверьте и попробуйте ещё раз.")
|
||
case "OTP_EXPIRED":
|
||
return String(localized: "Срок действия кода истёк. Запросите новый.")
|
||
case "TOO_MANY_ATTEMPTS":
|
||
return String(localized: "Слишком много попыток. Запросите новый код.")
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Тонкая обёртка над URLSession. Сессия better-auth хранится в cookie,
|
||
/// которые URLSession сохраняет в HTTPCookieStorage.shared автоматически.
|
||
nonisolated struct APIClient: Sendable {
|
||
let baseURL: URL
|
||
let session: URLSession
|
||
|
||
init(baseURL: URL = APIConfiguration.baseURL, session: URLSession = .shared) {
|
||
self.baseURL = baseURL
|
||
self.session = session
|
||
}
|
||
|
||
func send<Response: Decodable>(
|
||
_ method: String,
|
||
_ path: String,
|
||
query: [URLQueryItem] = [],
|
||
body: [String: String]? = nil,
|
||
as type: Response.Type = Response.self
|
||
) async throws -> Response {
|
||
var url = baseURL.appending(path: path)
|
||
if !query.isEmpty {
|
||
url.append(queryItems: query)
|
||
}
|
||
var request = URLRequest(url: url)
|
||
request.httpMethod = method
|
||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||
if let body {
|
||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
request.httpBody = try JSONEncoder().encode(body)
|
||
}
|
||
|
||
let data: Data
|
||
let response: URLResponse
|
||
do {
|
||
(data, response) = try await session.data(for: request)
|
||
} catch let error as URLError {
|
||
throw APIError.network(error)
|
||
}
|
||
|
||
guard let http = response as? HTTPURLResponse else {
|
||
throw APIError.invalidResponse
|
||
}
|
||
|
||
guard (200..<300).contains(http.statusCode) else {
|
||
if http.statusCode == 401 {
|
||
throw APIError.unauthorized
|
||
}
|
||
let body = try? JSONDecoder().decode(ErrorBody.self, from: data)
|
||
throw APIError.server(status: http.statusCode, code: body?.code, message: body?.message)
|
||
}
|
||
|
||
do {
|
||
return try Self.decoder.decode(Response.self, from: data)
|
||
} catch {
|
||
throw APIError.invalidResponse
|
||
}
|
||
}
|
||
|
||
/// Удаляет cookie сессии — на случай, если сервер недоступен в момент выхода.
|
||
func clearCookies() {
|
||
let storage = HTTPCookieStorage.shared
|
||
storage.cookies(for: baseURL)?.forEach(storage.deleteCookie)
|
||
}
|
||
|
||
private static let decoder: JSONDecoder = {
|
||
let decoder = JSONDecoder()
|
||
// Сервер отдаёт даты в ISO 8601 с миллисекундами: 2026-10-29T09:34:27.000Z
|
||
decoder.dateDecodingStrategy = .custom { decoder in
|
||
let container = try decoder.singleValueContainer()
|
||
let string = try container.decode(String.self)
|
||
if let date = try? Date(string, strategy: .iso8601.year().month().day().time(includingFractionalSeconds: true)) {
|
||
return date
|
||
}
|
||
if let date = try? Date(string, strategy: .iso8601) {
|
||
return date
|
||
}
|
||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Неизвестный формат даты: \(string)")
|
||
}
|
||
return decoder
|
||
}()
|
||
}
|
||
|
||
/// Тело ошибки. better-auth отдаёт `{ code: "INVALID_OTP", message }`,
|
||
/// а Nest — `{ data: null, code: 400, message }`, где message может быть массивом.
|
||
private nonisolated struct ErrorBody: Decodable {
|
||
let code: String?
|
||
let message: String?
|
||
|
||
private enum CodingKeys: String, CodingKey {
|
||
case code, message
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
code = try? container.decode(String.self, forKey: .code)
|
||
if let text = try? container.decode(String.self, forKey: .message) {
|
||
message = text
|
||
} else if let lines = try? container.decode([String].self, forKey: .message) {
|
||
message = lines.joined(separator: "\n")
|
||
} else {
|
||
message = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Обёртка ответов Nest-контроллеров: `{ data, code }`.
|
||
nonisolated struct Envelope<Payload: Decodable>: Decodable {
|
||
let data: Payload
|
||
}
|