import Foundation enum WallhavenError: Error, LocalizedError { case invalidURL case networkError(Error) case decodingError(Error) case noData case apiError(String) /// Wallhaven's own server is down or serving its maintenance page. Kept /// apart from `apiError` because the fault is upstream, not in the request, /// and the UI says so differently. case unavailable(String) /// These strings reach the user verbatim through the error banner. Without /// this, Swift bridges the enum to the opaque "Haven.WallhavenError error N" /// that says nothing about what actually failed. var errorDescription: String? { switch self { case .invalidURL: return "That isn't a valid Wallhaven URL." case .networkError(let error): return "Couldn't reach Wallhaven: \(error.localizedDescription)" case .decodingError: return "Wallhaven sent a response Haven couldn't read." case .noData: return "Wallhaven returned no data." case .apiError(let message): return message case .unavailable(let message): return message } } } /// Sort order for Wallhaven search results. Maps to the `sorting` query /// parameter on the public API. `random` returns a fresh batch per page. enum WallhavenSorting: String { case dateAdded = "date_added" case toplist case random } @MainActor protocol WallhavenSearching { func searchWallpapers( query: String, categories: String, purity: String, sorting: WallhavenSorting, resolutions: [String], ratios: [String], limit: Int, page: Int ) async throws -> ([Wallpaper], Meta?) } class WallhavenAPI: WallhavenSearching { private let session: URLSession init(session: URLSession = .shared) { self.session = session } func searchWallpapers( query: String, categories: String = "111", purity: String = "100", sorting: WallhavenSorting = .dateAdded, resolutions: [String] = [], ratios: [String] = [], limit: Int = 24, page: Int = 1 ) async throws -> ([Wallpaper], Meta?) { var queryItems = [ URLQueryItem(name: "q", value: query), URLQueryItem(name: "categories", value: categories), URLQueryItem(name: "purity", value: purity), URLQueryItem(name: "sorting", value: sorting.rawValue), URLQueryItem(name: "limit", value: String(limit)), URLQueryItem(name: "page", value: String(page)), ] if !resolutions.isEmpty { queryItems.append( URLQueryItem(name: "resolutions", value: resolutions.joined(separator: ","))) } if !ratios.isEmpty { queryItems.append(URLQueryItem(name: "ratios", value: ratios.joined(separator: ","))) } return try await performSearch(queryItems: queryItems) } /// Wallhaven is fronted by Cloudflare, so when a search fails the status code /// is the only thing that says whether the site is down (5xx), Haven has hit /// its rate limit (429), or the request itself was rejected. static func failure(forStatus status: Int) -> WallhavenError { switch status { case 429: return .apiError("Wallhaven rate limit reached (HTTP 429). Try again in a minute.") case 500...: return .unavailable("Wallhaven is unavailable right now (HTTP \(status)).") default: return .apiError("Wallhaven rejected the request (HTTP \(status)).") } } /// Wallhaven's status page is HTML; the API itself is always JSON, so any /// other content type means the response did not come from the API. static func isStatusPage(response: HTTPURLResponse) -> Bool { !(response.mimeType ?? "").contains("json") } private func performSearch(queryItems: [URLQueryItem]) async throws -> ([Wallpaper], Meta?) { var urlComponents = URLComponents( url: Configuration.baseURL.appendingPathComponent("search"), resolvingAgainstBaseURL: true)! urlComponents.queryItems = queryItems let url = urlComponents.url! let (data, response) = try await session.data(from: url) guard let httpResponse = response as? HTTPURLResponse else { throw WallhavenError.apiError("Wallhaven returned an unexpected response.") } guard (200...299).contains(httpResponse.statusCode) else { throw Self.failure(forStatus: httpResponse.statusCode) } // Wallhaven answers API calls with its HTML status page ("taking a little // nap") while its server is down, under a 200 status. Saying so beats // reporting a decoding failure for a body that was never going to decode. guard !Self.isStatusPage(response: httpResponse) else { throw WallhavenError.unavailable( "Wallhaven is offline right now — it's serving its status page.") } do { let decodedResponse = try JSONDecoder().decode(WallhavenResponse.self, from: data) return (decodedResponse.data, decodedResponse.meta) } catch { throw WallhavenError.decodingError(error) } } }