import Foundation import os /// Клиент публичного API Rapira. /// /// Транспортный слой без бизнес-логики: обработка статусов, декодирование /// и таймауты. Что делать с ошибками — решают вызывающие стороны. /// /// `nonisolated` — чтобы в app-таргете (MainActor по умолчанию) сетевые /// вызовы из акторов не прыгали через главный поток. nonisolated struct RapiraAPI: Sendable { enum APIError: Error { case invalidResponse case badStatus(Int) } static let shared = RapiraAPI() private let session: URLSession private static let logger = Logger(subsystem: AppGroup.id, category: "api") /// - Parameter session: подменяется в тестах; nil — сессия по умолчанию. init(session: URLSession? = nil) { self.session = session ?? Self.makeSession() } // MARK: - Методы /// Сводки по всем торговым парам. func symbolThumbs() async throws -> [SymbolThumb] { try await get([SymbolThumb].self, from: Endpoint.symbolThumbs.url) } /// Цены закрытия за окно `hours` часов с шагом `resolutionMinutes` минут. /// Ответ API — массив свечей `[ts, open, high, low, close, volume]`. func closes( for symbol: String, resolutionMinutes: Int = 15, hours: Int = 24, now: Date = .now ) async throws -> [Double] { let toMs = Int(now.timeIntervalSince1970 * 1000) let fromMs = toMs - hours * 3_600_000 let endpoint = Endpoint.history( symbol: symbol, resolutionMinutes: resolutionMinutes, fromMs: fromMs, toMs: toMs ) let candles = try await get([[Double]].self, from: endpoint.url) return candles.compactMap { $0.count > 4 ? $0[4] : nil } } /// SVG-иконка монеты с CDN. func iconData(for currency: String) async throws -> Data { try await data(from: Endpoint.icon(currency: currency).url) } // MARK: - Транспорт private func get(_ type: T.Type, from url: URL) async throws -> T { try JSONDecoder().decode(type, from: await data(from: url)) } private func data(from url: URL) async throws -> Data { let (data, response) = try await session.data(from: url) guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } guard (200..<300).contains(http.statusCode) else { Self.logger.error("HTTP \(http.statusCode) \(url.absoluteString, privacy: .public)") throw APIError.badStatus(http.statusCode) } return data } private static func makeSession() -> URLSession { // Виджет живёт секунды: URL-кэш не нужен (иконки кэшируются на диске), // а короткие таймауты не дают выйти за бюджет времени расширения. let configuration = URLSessionConfiguration.ephemeral configuration.timeoutIntervalForRequest = 10 configuration.timeoutIntervalForResource = 20 return URLSession(configuration: configuration) } } // MARK: - Эндпоинты private enum Endpoint { case symbolThumbs case history(symbol: String, resolutionMinutes: Int, fromMs: Int, toMs: Int) case icon(currency: String) /// Адреса собираются из констант — force unwrap безопасен. var url: URL { switch self { case .symbolThumbs: URL(string: "https://api.rapira.net/market/symbol-thumb")! case let .history(symbol, resolution, fromMs, toMs): { var components = URLComponents(string: "https://api.rapira.net/market/history")! components.queryItems = [ URLQueryItem(name: "symbol", value: symbol), URLQueryItem(name: "resolution", value: String(resolution)), URLQueryItem(name: "from", value: String(fromMs)), URLQueryItem(name: "to", value: String(toMs)), ] return components.url! }() case let .icon(currency): URL(string: "https://cdn.rapira.net/media/crypto/\(currency).svg")! } } }