1import Foundation23enum WallhavenError: Error, LocalizedError {4 case invalidURL5 case networkError(Error)6 case decodingError(Error)7 case noData8 case apiError(String)9 10 11 12 case unavailable(String)1314 15 16 17 var errorDescription: String? {18 switch self {19 case .invalidURL:20 return "That isn't a valid Wallhaven URL."21 case .networkError(let error):22 return "Couldn't reach Wallhaven: \(error.localizedDescription)"23 case .decodingError:24 return "Wallhaven sent a response Haven couldn't read."25 case .noData:26 return "Wallhaven returned no data."27 case .apiError(let message):28 return message29 case .unavailable(let message):30 return message31 }32 }33}34353637enum WallhavenSorting: String {38 case dateAdded = "date_added"39 case toplist40 case random41}4243@MainActor44protocol WallhavenSearching {45 func searchWallpapers(46 query: String, categories: String, purity: String, sorting: WallhavenSorting,47 resolutions: [String], ratios: [String], limit: Int, page: Int48 ) async throws -> ([Wallpaper], Meta?)49}5051class WallhavenAPI: WallhavenSearching {52 private let session: URLSession5354 init(session: URLSession = .shared) {55 self.session = session56 }5758 func searchWallpapers(59 query: String,60 categories: String = "111",61 purity: String = "100",62 sorting: WallhavenSorting = .dateAdded,63 resolutions: [String] = [],64 ratios: [String] = [],65 limit: Int = 24,66 page: Int = 167 ) async throws -> ([Wallpaper], Meta?) {68 var queryItems = [69 URLQueryItem(name: "q", value: query),70 URLQueryItem(name: "categories", value: categories),71 URLQueryItem(name: "purity", value: purity),72 URLQueryItem(name: "sorting", value: sorting.rawValue),73 URLQueryItem(name: "limit", value: String(limit)),74 URLQueryItem(name: "page", value: String(page)),75 ]7677 if !resolutions.isEmpty {78 queryItems.append(79 URLQueryItem(name: "resolutions", value: resolutions.joined(separator: ",")))80 }8182 if !ratios.isEmpty {83 queryItems.append(URLQueryItem(name: "ratios", value: ratios.joined(separator: ",")))84 }8586 return try await performSearch(queryItems: queryItems)87 }8889 90 91 92 static func failure(forStatus status: Int) -> WallhavenError {93 switch status {94 case 429:95 return .apiError("Wallhaven rate limit reached (HTTP 429). Try again in a minute.")96 case 500...:97 return .unavailable("Wallhaven is unavailable right now (HTTP \(status)).")98 default:99 return .apiError("Wallhaven rejected the request (HTTP \(status)).")100 }101 }102103 104 105 static func isStatusPage(response: HTTPURLResponse) -> Bool {106 !(response.mimeType ?? "").contains("json")107 }108109 private func performSearch(queryItems: [URLQueryItem]) async throws -> ([Wallpaper], Meta?) {110 var urlComponents = URLComponents(111 url: Configuration.baseURL.appendingPathComponent("search"), resolvingAgainstBaseURL: true)!112 urlComponents.queryItems = queryItems113114 let url = urlComponents.url!115116 let (data, response) = try await session.data(from: url)117118 guard let httpResponse = response as? HTTPURLResponse else {119 throw WallhavenError.apiError("Wallhaven returned an unexpected response.")120 }121122 guard (200...299).contains(httpResponse.statusCode) else {123 throw Self.failure(forStatus: httpResponse.statusCode)124 }125126 127 128 129 guard !Self.isStatusPage(response: httpResponse) else {130 throw WallhavenError.unavailable(131 "Wallhaven is offline right now — it's serving its status page.")132 }133134 do {135 let decodedResponse = try JSONDecoder().decode(WallhavenResponse.self, from: data)136 return (decodedResponse.data, decodedResponse.meta)137 } catch {138 throw WallhavenError.decodingError(error)139 }140 }141}