114 lines
4.5 KiB
Swift
114 lines
4.5 KiB
Swift
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<T: Decodable>(_ 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")!
|
||
}
|
||
}
|
||
}
|