// // 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( _ 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: Decodable { let data: Payload }