WallhavenAPI.swift

4.8 KB · raw

1import Foundation23enum WallhavenError: Error, LocalizedError {4  case invalidURL5  case networkError(Error)6  case decodingError(Error)7  case noData8  case apiError(String)9  /// Wallhaven's own server is down or serving its maintenance page. Kept10  /// apart from `apiError` because the fault is upstream, not in the request,11  /// and the UI says so differently.12  case unavailable(String)1314  /// These strings reach the user verbatim through the error banner. Without15  /// this, Swift bridges the enum to the opaque "Haven.WallhavenError error N"16  /// that says nothing about what actually failed.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}3435/// Sort order for Wallhaven search results. Maps to the `sorting` query36/// parameter on the public API. `random` returns a fresh batch per page.37enum 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  /// Wallhaven is fronted by Cloudflare, so when a search fails the status code90  /// is the only thing that says whether the site is down (5xx), Haven has hit91  /// its rate limit (429), or the request itself was rejected.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  /// Wallhaven's status page is HTML; the API itself is always JSON, so any104  /// other content type means the response did not come from the API.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    // Wallhaven answers API calls with its HTML status page ("taking a little127    // nap") while its server is down, under a 200 status. Saying so beats128    // reporting a decoding failure for a body that was never going to decode.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}