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 } }