// ── 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 } /// Test-only initializer accepting a custom URLSessionConfiguration /// so callers can inject MockURLProtocol for intercepting requests. public init(config: BLPlatformConfig, sessionConfiguration: URLSessionConfiguration) { self.config = config session = URLSession(configuration: sessionConfiguration) 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) } /// Perform an authenticated request with pre-encoded JSON body data. /// Used by passkey methods that need to send raw JSON (e.g. from JSONSerialization). public func rawRequest( path: String, method: String = "GET", rawBody: Data? = 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") } request.httpBody = rawBody 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 { 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: - Request Builder /// Build an authenticated URLRequest (used by BLBroadcastClient, BLSurveyClient). public func buildRequest( path: String, method: String = "GET", body: [String: Any]? = nil ) throws -> URLRequest { 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 JSONSerialization.data(withJSONObject: body) } return request } // MARK: - Encoder/Decoder Access public var jsonEncoder: JSONEncoder { encoder } public var jsonDecoder: JSONDecoder { decoder } } // MARK: - Errors /// Legacy alias used by BLBroadcastClient, BLSurveyClient, BLDeepLinkRouter. public enum BLPlatformError: LocalizedError { case requestFailed(String) public var errorDescription: String? { switch self { case .requestFailed(let msg): return msg } } } 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 } }