Files
2026-11-03 16:48:49 +03:00

176 lines
6.8 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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
}