feat: add daily fiat and crypto currency conversion
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
|
||||
public struct CurrencyRates: Sendable {
|
||||
/// The provider's daily snapshot date, not a market quote timestamp.
|
||||
public let date: String
|
||||
/// Units of each lowercase currency code per EUR.
|
||||
public let rates: [String: Double]
|
||||
|
||||
public init(date: String, rates: [String: Double]) {
|
||||
self.date = date
|
||||
self.rates = rates
|
||||
}
|
||||
}
|
||||
|
||||
public enum CurrencyAPI {
|
||||
private struct Snapshot: Decodable {
|
||||
let date: String
|
||||
let eur: [String: Double]
|
||||
}
|
||||
|
||||
private enum FetchError: LocalizedError {
|
||||
case invalidResponse
|
||||
case http(Int)
|
||||
case invalidSnapshot
|
||||
case unavailable(String, String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse: "Currency provider returned a non-HTTP response."
|
||||
case .http(let status): "Currency provider returned HTTP \(status)."
|
||||
case .invalidSnapshot: "Currency provider returned an invalid daily snapshot."
|
||||
case .unavailable(let primary, let fallback):
|
||||
"Currency rates unavailable. CDN: \(primary) Fallback: \(fallback)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func fetch() async throws -> CurrencyRates {
|
||||
try await fetch { request in
|
||||
try await URLSession.shared.data(for: request)
|
||||
}
|
||||
}
|
||||
|
||||
// The provider documents this CDN → Cloudflare fallback:
|
||||
// https://github.com/fawazahmed0/exchange-api#additional-fallback-url-on-cloudflare
|
||||
// Inject only the network boundary so tests never make real requests.
|
||||
static func fetch(
|
||||
load: @Sendable (URLRequest) async throws -> (Data, URLResponse)
|
||||
) async throws -> CurrencyRates {
|
||||
let endpoints = [
|
||||
"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/eur.json",
|
||||
"https://latest.currency-api.pages.dev/v1/currencies/eur.json",
|
||||
]
|
||||
var primaryFailure = ""
|
||||
for (index, endpoint) in endpoints.enumerated() {
|
||||
try Task.checkCancellation()
|
||||
let request = URLRequest(url: URL(string: endpoint)!, cachePolicy: .reloadIgnoringLocalCacheData,
|
||||
timeoutInterval: 12)
|
||||
do {
|
||||
let (data, response) = try await load(request)
|
||||
try Task.checkCancellation()
|
||||
guard let response = response as? HTTPURLResponse else { throw FetchError.invalidResponse }
|
||||
guard (200...299).contains(response.statusCode) else { throw FetchError.http(response.statusCode) }
|
||||
let snapshot: Snapshot
|
||||
do { snapshot = try JSONDecoder().decode(Snapshot.self, from: data) }
|
||||
catch { throw FetchError.invalidSnapshot }
|
||||
guard validDate(snapshot.date), snapshot.eur["eur"] == 1,
|
||||
snapshot.eur.allSatisfy({ code, rate in
|
||||
!code.isEmpty && code.utf8.allSatisfy {
|
||||
(97...122).contains($0) || (48...57).contains($0)
|
||||
} && rate.isFinite && rate > 0
|
||||
}) else { throw FetchError.invalidSnapshot }
|
||||
return CurrencyRates(date: snapshot.date, rates: snapshot.eur)
|
||||
} catch {
|
||||
try Task.checkCancellation()
|
||||
if error is CancellationError || (error as? URLError)?.code == .cancelled { throw error }
|
||||
if index == 0 { primaryFailure = error.localizedDescription }
|
||||
else { throw FetchError.unavailable(primaryFailure, error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
throw FetchError.invalidResponse
|
||||
}
|
||||
|
||||
private static func validDate(_ text: String) -> Bool {
|
||||
let bytes = Array(text.utf8)
|
||||
guard bytes.count == 10, bytes[4] == 45, bytes[7] == 45,
|
||||
bytes.enumerated().allSatisfy({ index, byte in
|
||||
index == 4 || index == 7 || (48...57).contains(byte)
|
||||
}), let year = Int(text.prefix(4)), year > 0,
|
||||
let month = Int(text.dropFirst(5).prefix(2)),
|
||||
let day = Int(text.suffix(2)) else { return false }
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
let components = DateComponents(year: year, month: month, day: day)
|
||||
guard let date = calendar.date(from: components) else { return false }
|
||||
let actual = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
return actual.year == year && actual.month == month && actual.day == day
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user