WallhavenAPI.swift

4.5 KB · raw

1import Foundation23enum WallhavenError: Error, LocalizedError {4  case invalidURL5  case networkError(Error)6  case decodingError(Error)7  case noData8  case apiError(String)910  /// These strings reach the user verbatim through the error banner. Without11  /// this, Swift bridges the enum to the opaque "Haven.WallhavenError error N"12  /// that says nothing about what actually failed.13  var errorDescription: String? {14    switch self {15    case .invalidURL:16      return "That isn't a valid Wallhaven URL."17    case .networkError(let error):18      return "Couldn't reach Wallhaven: \(error.localizedDescription)"19    case .decodingError:20      return "Wallhaven sent a response Haven couldn't read."21    case .noData:22      return "Wallhaven returned no data."23    case .apiError(let message):24      return message25    }26  }27}2829/// Sort order for Wallhaven search results. Maps to the `sorting` query30/// parameter on the public API. `random` returns a fresh batch per page.31enum WallhavenSorting: String {32  case dateAdded = "date_added"33  case toplist34  case random35}3637@MainActor38protocol WallhavenSearching {39  func searchWallpapers(40    query: String, categories: String, purity: String, sorting: WallhavenSorting,41    resolutions: [String], ratios: [String], limit: Int, page: Int42  ) async throws -> ([Wallpaper], Meta?)43}4445class WallhavenAPI: WallhavenSearching {46  private let session: URLSession4748  init(session: URLSession = .shared) {49    self.session = session50  }5152  func searchWallpapers(53    query: String,54    categories: String = "111",55    purity: String = "100",56    sorting: WallhavenSorting = .dateAdded,57    resolutions: [String] = [],58    ratios: [String] = [],59    limit: Int = 24,60    page: Int = 161  ) async throws -> ([Wallpaper], Meta?) {62    var queryItems = [63      URLQueryItem(name: "q", value: query),64      URLQueryItem(name: "categories", value: categories),65      URLQueryItem(name: "purity", value: purity),66      URLQueryItem(name: "sorting", value: sorting.rawValue),67      URLQueryItem(name: "limit", value: String(limit)),68      URLQueryItem(name: "page", value: String(page)),69    ]7071    if !resolutions.isEmpty {72      queryItems.append(73        URLQueryItem(name: "resolutions", value: resolutions.joined(separator: ",")))74    }7576    if !ratios.isEmpty {77      queryItems.append(URLQueryItem(name: "ratios", value: ratios.joined(separator: ",")))78    }7980    return try await performSearch(queryItems: queryItems)81  }8283  /// Wallhaven is fronted by Cloudflare, so when a search fails the status code84  /// is the only thing that says whether the site is down (5xx), Haven has hit85  /// its rate limit (429), or the request itself was rejected.86  static func message(forStatus status: Int) -> String {87    switch status {88    case 429:89      return "Wallhaven rate limit reached (HTTP 429). Try again in a minute."90    case 500...:91      return "Wallhaven is unavailable right now (HTTP \(status))."92    default:93      return "Wallhaven rejected the request (HTTP \(status))."94    }95  }9697  /// Wallhaven's status page is HTML; the API itself is always JSON, so any98  /// other content type means the response did not come from the API.99  static func isStatusPage(response: HTTPURLResponse) -> Bool {100    !(response.mimeType ?? "").contains("json")101  }102103  private func performSearch(queryItems: [URLQueryItem]) async throws -> ([Wallpaper], Meta?) {104    var urlComponents = URLComponents(105      url: Configuration.baseURL.appendingPathComponent("search"), resolvingAgainstBaseURL: true)!106    urlComponents.queryItems = queryItems107108    let url = urlComponents.url!109110    let (data, response) = try await session.data(from: url)111112    guard let httpResponse = response as? HTTPURLResponse else {113      throw WallhavenError.apiError("Wallhaven returned an unexpected response.")114    }115116    guard (200...299).contains(httpResponse.statusCode) else {117      throw WallhavenError.apiError(Self.message(forStatus: httpResponse.statusCode))118    }119120    // Wallhaven answers API calls with its HTML status page ("taking a little121    // nap") while its server is down, under a 200 status. Saying so beats122    // reporting a decoding failure for a body that was never going to decode.123    guard !Self.isStatusPage(response: httpResponse) else {124      throw WallhavenError.apiError(125        "Wallhaven is offline right now — it's serving its status page.")126    }127128    do {129      let decodedResponse = try JSONDecoder().decode(WallhavenResponse.self, from: data)130      return (decodedResponse.data, decodedResponse.meta)131    } catch {132      throw WallhavenError.decodingError(error)133    }134  }135}