Files
2026-08-12 07:06:01 +07:00

114 lines
4.5 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")!
}
}
}