This commit is contained in:
vadim committed 2026-10-29 13:12:45 +03:00
1 parent 558bb005f4
commit 9ed2f17768
14 files changed
+1207 -94

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
//
// 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 = "http://192.168.1.77:3000"
#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
}