auth and projects
This commit is contained in:
1 parent
558bb005f4
commit
0ba2db0ec2
14 files changed
+1207
-94
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user