Files
mayday/Mayday/Services/HTTPClient.swift
T

366 lines
13 KiB
Swift

import Foundation
enum APIError: Error, LocalizedError {
case invalidURL
case unauthorized
case mfaRequired
case validationError([String: [String]])
case serverError(String)
case networkError(Error)
case decodingError(Error)
var errorDescription: String? {
switch self {
case .invalidURL: return String(localized: "error_invalid_url")
case .unauthorized: return String(localized: "error_invalid_credentials")
case .mfaRequired: return String(localized: "error_mfa_required")
case .validationError(let errors):
return errors.values.flatMap { $0 }.joined(separator: ", ")
case .serverError(let message): return message
case .networkError(let error): return error.localizedDescription
case .decodingError(let error): return error.localizedDescription
}
}
}
struct APIResponse<T: Decodable>: Decodable {
let data: T
}
struct Pagination: Decodable, Sendable {
let total: Int
let limit: Int
let offset: Int
let hasMore: Bool
enum CodingKeys: String, CodingKey {
case total, limit, offset
case hasMore = "has_more"
}
}
struct PaginatedResponse<T: Decodable>: Decodable {
let data: T
let pagination: Pagination
}
struct APIErrorResponse: Decodable {
let message: String
let errors: [String: [String]]?
}
enum APIService {
case sso
case notification
}
enum Endpoint {
// Auth
case login(email: String, password: String)
case register(email: String, password: String)
case verifyEmail(email: String, code: String)
case resendCode(email: String)
case refresh(refreshToken: String)
case logout(refreshToken: String)
// Users
case getMe
case getSessions
case deleteSession(id: UUID)
case logoutAll
case changePassword(current: String, new: String)
// Notifications
case getNotifications(limit: Int, offset: Int, unreadOnly: Bool, scope: String?)
case markAsRead(id: UUID)
case markAllAsRead(scope: String?)
// Devices
case listDevices
case registerDevice(token: String, platform: String)
case unregisterDevice(id: UUID)
case unregisterDeviceByToken(token: String)
// Preferences
case getPreferences
case upsertPreference(channel: String, enabled: Bool, config: [String: String]?)
var service: APIService {
switch self {
case .login, .register, .verifyEmail, .resendCode, .refresh, .logout,
.getMe, .getSessions, .deleteSession, .logoutAll, .changePassword:
return .sso
case .getNotifications, .markAsRead, .markAllAsRead,
.listDevices, .registerDevice, .unregisterDevice, .unregisterDeviceByToken,
.getPreferences, .upsertPreference:
return .notification
}
}
var path: String {
switch self {
case .login: return "/auth/login"
case .register: return "/auth/register"
case .verifyEmail: return "/auth/verify-email"
case .resendCode: return "/auth/resend-code"
case .refresh: return "/auth/refresh"
case .logout: return "/auth/logout"
case .getMe: return "/users/me"
case .getSessions: return "/users/me/sessions"
case .deleteSession(let id): return "/users/me/sessions/\(id.uuidString)"
case .logoutAll: return "/users/me/logout-all"
case .changePassword: return "/users/me/change-password"
case .getNotifications: return "/notifications"
case .markAsRead(let id): return "/notifications/\(id.uuidString)/read"
case .markAllAsRead: return "/notifications/read-all"
case .listDevices: return "/devices"
case .registerDevice: return "/devices"
case .unregisterDevice(let id): return "/devices/\(id.uuidString)"
case .unregisterDeviceByToken: return "/devices/by-token"
case .getPreferences: return "/preferences"
case .upsertPreference: return "/preferences"
}
}
var method: String {
switch self {
case .getMe, .getSessions, .getNotifications, .listDevices, .getPreferences:
return "GET"
case .deleteSession, .unregisterDevice, .unregisterDeviceByToken:
return "DELETE"
case .upsertPreference:
return "PUT"
default:
return "POST"
}
}
var requiresAuth: Bool {
switch self {
case .login, .register, .verifyEmail, .resendCode, .refresh, .logout:
return false
default:
return true
}
}
var body: [String: Any]? {
switch self {
case .login(let email, let password):
return ["email": email, "password": password]
case .register(let email, let password):
return ["email": email, "password": password]
case .verifyEmail(let email, let code):
return ["email": email, "code": code]
case .resendCode(let email):
return ["email": email]
case .refresh(let token):
return ["refresh_token": token]
case .logout(let token):
return ["refresh_token": token]
case .changePassword(let current, let new):
return ["current_password": current, "new_password": new]
case .registerDevice(let token, let platform):
return ["token": token, "platform": platform]
case .getNotifications(let limit, let offset, let unreadOnly, let scope):
var params: [String: Any] = ["limit": limit, "offset": offset]
if unreadOnly { params["unread_only"] = true }
if let scope { params["scope"] = scope }
return params
case .markAllAsRead(let scope):
if let scope { return ["scope": scope] }
return nil
case .upsertPreference(let channel, let enabled, let config):
var params: [String: Any] = ["channel": channel, "enabled": enabled]
if let config { params["config"] = config }
return params
case .unregisterDeviceByToken(let token):
return ["token": token]
default:
return nil
}
}
}
actor HTTPClient {
static let shared = HTTPClient()
private let ssoBaseURL: String
private let notificationBaseURL: String
private let keychain = KeychainService.shared
// Single in-flight refresh task; concurrent 401s await this rather than racing.
private var refreshTask: Task<Void, Error>?
private init() {
ssoBaseURL = "https://id.robonen.ru"
notificationBaseURL = "https://notify.robonen.ru"
}
private func baseURL(for service: APIService) -> String {
switch service {
case .sso: return ssoBaseURL
case .notification: return notificationBaseURL
}
}
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
let data = try await executeRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth)
// 204 No Content return empty decodable if possible
if data.isEmpty {
if let empty = EmptyResponse() as? T {
return empty
}
}
do {
let wrapped = try Self.jsonDecoder.decode(APIResponse<T>.self, from: data)
return wrapped.data
} catch {
throw APIError.decodingError(error)
}
}
func requestPaginated<T: Decodable>(_ endpoint: Endpoint) async throws -> (T, Pagination) {
let data = try await executeRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth)
do {
let wrapped = try Self.jsonDecoder.decode(PaginatedResponse<T>.self, from: data)
return (wrapped.data, wrapped.pagination)
} catch {
throw APIError.decodingError(error)
}
}
// Go's default time.Time marshaling uses RFC3339Nano (fractional seconds),
// which the built-in .iso8601 strategy rejects. Two pre-built formatters
// cover both forms; ISO8601DateFormatter is documented thread-safe for read.
nonisolated(unsafe) private static let isoWithFractional: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
nonisolated(unsafe) private static let isoWithoutFractional: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
private static let jsonDecoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { d in
let container = try d.singleValueContainer()
let s = try container.decode(String.self)
if let date = isoWithFractional.date(from: s) ?? isoWithoutFractional.date(from: s) {
return date
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date: \(s)")
}
return decoder
}()
private func executeRequest(_ endpoint: Endpoint, retryOnUnauthorized: Bool) async throws -> Data {
guard let url = URL(string: baseURL(for: endpoint.service) + endpoint.path) else {
throw APIError.invalidURL
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = endpoint.method
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
if endpoint.requiresAuth, let token = keychain.loadAccessToken() {
urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body = endpoint.body {
// DELETE/GET don't carry a JSON body; encode params on the URL instead
// so endpoints like /devices/by-token?token=... work.
if endpoint.method == "GET" || endpoint.method == "DELETE" {
if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
components.queryItems = body.map { key, value in
URLQueryItem(name: key, value: "\(value)")
}
urlRequest.url = components.url
}
} else {
do {
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
} catch {
throw APIError.networkError(error)
}
}
}
let (data, response): (Data, URLResponse)
do {
(data, response) = try await URLSession.shared.data(for: urlRequest)
} catch {
throw APIError.networkError(error)
}
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.networkError(URLError(.badServerResponse))
}
if httpResponse.statusCode == 401 && retryOnUnauthorized {
do {
try await ensureTokenRefreshed()
} catch {
throw APIError.unauthorized
}
return try await executeRequest(endpoint, retryOnUnauthorized: false)
}
if httpResponse.statusCode == 401 {
keychain.clearTokens()
throw APIError.unauthorized
}
if httpResponse.statusCode == 422 {
if let errorResponse = try? JSONDecoder().decode(APIErrorResponse.self, from: data) {
throw APIError.validationError(errorResponse.errors ?? [:])
}
}
if !(200..<300).contains(httpResponse.statusCode) {
if let errorResponse = try? JSONDecoder().decode(APIErrorResponse.self, from: data) {
throw APIError.serverError(errorResponse.message)
}
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
}
if httpResponse.statusCode == 204 {
return Data()
}
return data
}
/// Ensures tokens are refreshed exactly once even when multiple requests receive 401
/// concurrently. All callers await the same Task; only one network request is made.
private func ensureTokenRefreshed() async throws {
if let existing = refreshTask {
try await existing.value
return
}
guard let refreshToken = keychain.loadRefreshToken() else {
keychain.clearTokens()
throw APIError.unauthorized
}
let task = Task<Void, Error> {
let response: TokenRefreshResponse = try await self.request(.refresh(refreshToken: refreshToken))
try self.keychain.saveTokens(response.tokens)
}
refreshTask = task
do {
try await task.value
refreshTask = nil
} catch {
refreshTask = nil
keychain.clearTokens()
throw error
}
}
}
struct TokenRefreshResponse: Decodable {
let tokens: TokenPair
}