// ── Platform HTTP Client ───────────────────────────────────── // Generic URLSession wrapper with auth header injection, x-request-id, // timeout, and product ID header. Used by all BL* services. import Foundation public final class BLPlatformClient: @unchecked Sendable { public let config: BLPlatformConfig private let session: URLSession private let encoder: JSONEncoder private let decoder: JSONDecoder /// Auth token injected by BLAuthClient after login/refresh. private var _authToken: String? private let tokenLock = NSLock() public var authToken: String? { get { tokenLock.lock(); defer { tokenLock.unlock() }; return _authToken } set { tokenLock.lock(); defer { tokenLock.unlock() }; _authToken = newValue } } public init(config: BLPlatformConfig, timeoutSeconds: TimeInterval = 15) { self.config = config let urlConfig = URLSessionConfiguration.default urlConfig.timeoutIntervalForRequest = timeoutSeconds urlConfig.waitsForConnectivity = true session = URLSession(configuration: urlConfig) encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 } // MARK: - Public Request Methods /// Perform an authenticated request and decode the response. public func request( path: String, method: String = "GET", body: (any Encodable)? = nil, responseType: T.Type ) async throws -> T { let (data, _) = try await rawRequest(path: path, method: method, body: body) return try decoder.decode(T.self, from: data) } /// Perform an authenticated request, returning raw (Data, HTTPURLResponse). public func rawRequest( path: String, method: String = "GET", body: (any Encodable)? = nil ) async throws -> (Data, HTTPURLResponse) { guard let url = URL(string: "\(config.baseURL)\(path)") else { throw BLNetworkError.invalidURL(path) } var request = URLRequest(url: url) request.httpMethod = method request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") if let token = authToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } if let body { request.httpBody = try encoder.encode(body) } let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw BLNetworkError.invalidResponse } guard (200...299).contains(http.statusCode) else { // Try to extract server error message let message: String? = { if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let msg = json["message"] as? String { return msg } return nil }() throw BLNetworkError.httpError(statusCode: http.statusCode, message: message) } return (data, http) } /// Fire-and-forget POST (used by telemetry — errors silently ignored). public func fireAndForget(path: String, body: Data) { guard let url = URL(string: "\(config.baseURL)\(path)") else { return } var request = URLRequest(url: url) request.httpMethod = "POST" request.httpBody = body request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") request.timeoutInterval = 10 if let token = authToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } session.dataTask(with: request) { _, _, _ in }.resume() } // MARK: - Encoder/Decoder Access public var jsonEncoder: JSONEncoder { encoder } public var jsonDecoder: JSONDecoder { decoder } } // MARK: - Errors public enum BLNetworkError: LocalizedError { case invalidURL(String) case invalidResponse case httpError(statusCode: Int, message: String?) case notAuthenticated public var errorDescription: String? { switch self { case .invalidURL(let path): return "Invalid URL: \(path)" case .invalidResponse: return "Invalid server response" case .httpError(let code, let msg): return msg ?? "Server error (\(code))" case .notAuthenticated: return "Not signed in" } } public var statusCode: Int? { if case .httpError(let code, _) = self { return code } return nil } }