// // FetchService.swift // Loader // // Created by samara on 14.03.2025. // import Foundation // MARK: - Class public class NBFetchService { public enum NBFetchServiceError: Error, LocalizedError { case invalidURL case networkError(Error) case httpError(Int) case noData case parsingError(Error) /// The source is private and nobody is signed in on this device. /// /// Its own case rather than an `httpError(403)`, because it is the one /// refusal the user can do something about: the app answers it with a /// sign-in screen instead of an error row. `stranded` is the operator /// having closed the source outright, with nobody who could sign in and /// no invitation outstanding, which is the one case the screen has /// nothing to offer for. case locked(stranded: Bool, note: String?) public var errorDescription: String? { switch self { case .invalidURL: "The URL is invalid." case .networkError(let error): "Network error: \(error.localizedDescription)" case .httpError(let code): "The server returned HTTP \(code)." case .noData: "No data received." case .parsingError(let error): "Failed to parse data: \(error.localizedDescription)" case .locked: "This source is private." } } } /// The shape of the refusal FTStore Admin sends when a source is private. private struct _LockedBody: Decodable { let code: String? /// On, with nobody who can sign in. Omitted when somebody could, so its /// absence reads as "there is a form worth showing". let stranded: Bool? let note: String? } public init() {} /// Identifies the app to its repo backend. The server gates its feed on this, so it /// must be deterministic and start with `FTStore`/`FTStore-Manager`. Derived from /// the running app's bundle so the same code yields the right value in both apps. public static let userAgent: String = { let info = Bundle.main.infoDictionary let version = (info?["CFBundleShortVersionString"] as? String) ?? "1.0" let bundleId = Bundle.main.bundleIdentifier ?? "app.ftstore" let product = bundleId == "app.ftstore.manager" ? "FTStore-Manager" : "FTStore" return "\(product)/\(version) (\(bundleId))" }() } // MARK: - Class extension: fetch extension NBFetchService { public func fetch( from urlString: String, headers: [String: String] = [:], completion: @escaping (Result) -> Void ) { guard let url = URL(string: urlString) else { completion(.failure(NBFetchServiceError.invalidURL)) return } fetch(from: url, headers: headers, completion: completion) } /// - Parameter headers: Anything the *caller* has decided this particular URL /// should carry. A plain dictionary and not a hook, because deciding which /// hosts may see a credential is the app's business and this package has no /// idea what FTStore's server is: it copies what it is handed onto the /// request and knows nothing else about it. public func fetch( from url: URL, headers: [String: String] = [:], completion: @escaping (Result) -> Void ) { DispatchQueue.global(qos: .userInitiated).async { // Always pull a fresh copy: source feeds change frequently, and the // default protocol cache policy would otherwise serve a stale JSON // (showing versions that no longer exist in the live manifest). var request = URLRequest(url: url) request.cachePolicy = .reloadIgnoringLocalCacheData request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) } let task = URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { completion(.failure(NBFetchServiceError.networkError(error))) return } // Surface the status code before decoding: an error page (or a // JSON error envelope from the repo host) would otherwise fail // to decode and be reported as an unhelpful "parsing error". if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { // A private source answers 403 with a code saying so. Told // apart from every other refusal because the caller can offer // to sign in for this one, and cannot offer anything for the // rest. if http.statusCode == 403, let data, let body = try? JSONDecoder().decode(_LockedBody.self, from: data), body.code == "account_required" { completion(.failure(NBFetchServiceError.locked( stranded: body.stranded ?? false, note: body.note ))) return } completion(.failure(NBFetchServiceError.httpError(http.statusCode))) return } guard let data = data else { completion(.failure(NBFetchServiceError.noData)) return } do { let decoder = JSONDecoder() let decodedData = try decoder.decode(T.self, from: data) completion(.success(decodedData)) } catch { completion(.failure(NBFetchServiceError.parsingError(error))) } } task.resume() } } }