This commit is contained in:
2026-08-12 07:06:01 +07:00
parent 1025658518
commit 9bc96dbc2d
39 changed files with 1682 additions and 658 deletions
+113
View File
@@ -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")!
}
}
}