// // Network.swift // Werkout_ios // // Created by Trey Tartt on 6/19/23. // import Foundation import SharedCore private let runtimeReporter = RuntimeReporter.shared private let requestTimeout: TimeInterval = 30 enum FetchableError: Error { case apiError(Error) case noData case decodeError(Error) case endOfFileError case noPostData case noToken case statusError(Int, String?) } extension FetchableError: LocalizedError { var errorDescription: String? { switch self { case .apiError(let error): return "API error: \(error.localizedDescription)" case .noData: return "No response data was returned." case .decodeError(let error): return "Failed to decode response: \(error.localizedDescription)" case .endOfFileError: return "Unexpected end of file while parsing response." case .noPostData: return "Missing POST payload." case .noToken: return "Authentication token is missing or expired." case .statusError(let statusCode, _): return "Request failed with status code \(statusCode)." } } } protocol Fetchable { associatedtype Response: Codable var attachToken: Bool { get } var baseURL: String { get } var endPoint: String { get } func fetch(completion: @escaping (Result) -> Void) } protocol Postable: Fetchable { var postableData: [String: Any]? { get } var successStatus: Int { get } } extension Fetchable { var baseURL: String { BaseURLs.currentBaseURL } var attachToken: Bool { true } func fetch(completion: @escaping (Result) -> Void) { guard let url = URL(string: baseURL + endPoint) else { completeOnMain(completion, with: .failure(.noData)) return } var request = URLRequest(url: url, timeoutInterval: requestTimeout) if attachToken { guard let token = UserStore.shared.token else { runtimeReporter.recordWarning("Missing auth token", metadata: ["method": "GET", "endpoint": endPoint]) completeOnMain(completion, with: .failure(.noToken)) return } request.addValue(token, forHTTPHeaderField: "Authorization") } request.addValue("application/json", forHTTPHeaderField: "Content-Type") let task = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in if let error { runtimeReporter.recordError( "GET request failed", metadata: ["url": url.absoluteString, "error": error.localizedDescription] ) completeOnMain(completion, with: .failure(.apiError(error))) return } if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { let responseBody = data.flatMap { String(data: $0, encoding: .utf8) } handleHTTPFailure(statusCode: httpResponse.statusCode, responseBody: responseBody, endpoint: endPoint, method: "GET") completeOnMain(completion, with: .failure(.statusError(httpResponse.statusCode, responseBody))) return } guard let data else { runtimeReporter.recordError("GET request returned no data", metadata: ["url": url.absoluteString]) completeOnMain(completion, with: .failure(.noData)) return } do { let model = try JSONDecoder().decode(Response.self, from: data) completeOnMain(completion, with: .success(model)) } catch { runtimeReporter.recordError( "Failed decoding GET response", metadata: ["url": url.absoluteString, "error": error.localizedDescription] ) completeOnMain(completion, with: .failure(.decodeError(error))) } }) task.resume() } } extension Postable { func fetch(completion: @escaping (Result) -> Void) { guard let postableData else { completeOnMain(completion, with: .failure(.noPostData)) return } guard let url = URL(string: baseURL + endPoint) else { completeOnMain(completion, with: .failure(.noData)) return } let postData: Data do { postData = try JSONSerialization.data(withJSONObject: postableData) } catch { runtimeReporter.recordError( "Failed encoding POST payload", metadata: ["url": url.absoluteString, "error": error.localizedDescription] ) completeOnMain(completion, with: .failure(.apiError(error))) return } var request = URLRequest(url: url, timeoutInterval: requestTimeout) if attachToken { guard let token = UserStore.shared.token else { runtimeReporter.recordWarning("Missing auth token", metadata: ["method": "POST", "endpoint": endPoint]) completeOnMain(completion, with: .failure(.noToken)) return } request.addValue(token, forHTTPHeaderField: "Authorization") } request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" request.httpBody = postData let task = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in if let error { runtimeReporter.recordError( "POST request failed", metadata: ["url": url.absoluteString, "error": error.localizedDescription] ) completeOnMain(completion, with: .failure(.apiError(error))) return } if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != successStatus { let responseBody = data.flatMap { String(data: $0, encoding: .utf8) } handleHTTPFailure(statusCode: httpResponse.statusCode, responseBody: responseBody, endpoint: endPoint, method: "POST") completeOnMain(completion, with: .failure(.statusError(httpResponse.statusCode, responseBody))) return } guard let data else { runtimeReporter.recordError("POST request returned no data", metadata: ["url": url.absoluteString]) completeOnMain(completion, with: .failure(.noData)) return } do { let model = try JSONDecoder().decode(Response.self, from: data) completeOnMain(completion, with: .success(model)) } catch { runtimeReporter.recordError( "Failed decoding POST response", metadata: ["url": url.absoluteString, "error": error.localizedDescription] ) completeOnMain(completion, with: .failure(.decodeError(error))) } }) task.resume() } } private func handleHTTPFailure(statusCode: Int, responseBody: String?, endpoint: String, method: String) { runtimeReporter.recordError( "HTTP request failed", metadata: [ "method": method, "endpoint": endpoint, "status_code": "\(statusCode)", "has_body": responseBody == nil ? "false" : "true" ] ) UserStore.shared.handleUnauthorizedResponse(statusCode: statusCode, responseBody: responseBody) } private func completeOnMain( _ completion: @escaping (Result) -> Void, with result: Result ) { if Thread.isMainThread { completion(result) return } DispatchQueue.main.async { completion(result) } }