feat: v2
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
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")!
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user