import Foundation // MARK: - Protocol /// Abstracts the network layer for testability. public protocol ConsentAPIProtocol: Sendable { /// Fetches the effective site configuration from the API. func fetchConfig(siteId: String) async throws -> ConsentConfig /// Posts a consent record to the server. func postConsent(_ payload: ConsentPayload) async throws } // MARK: - Errors /// Errors that can be thrown by the CMP API client. public enum ConsentAPIError: Error, LocalizedError { case invalidURL case unexpectedStatusCode(Int) case decodingFailure(Error) case networkFailure(Error) public var errorDescription: String? { switch self { case .invalidURL: return "The constructed API URL is invalid." case .unexpectedStatusCode(let code): return "The server returned an unexpected HTTP status code: \(code)." case .decodingFailure(let underlying): return "Failed to decode the server response: \(underlying.localizedDescription)" case .networkFailure(let underlying): return "A network error occurred: \(underlying.localizedDescription)" } } } // MARK: - Consent Payload /// The request body sent when recording a consent event. public struct ConsentPayload: Codable, Sendable { public let siteId: String public let visitorId: String public let platform: String // Always "ios" public let accepted: [String] // ConsentCategory raw values public let rejected: [String] public let consentedAt: Date public let bannerVersion: String? public let userAgent: String? public let tcString: String? public init( siteId: String, visitorId: String, accepted: [ConsentCategory], rejected: [ConsentCategory], consentedAt: Date, bannerVersion: String?, userAgent: String? = nil, tcString: String? = nil ) { self.siteId = siteId self.visitorId = visitorId self.platform = "ios" self.accepted = accepted.map(\.rawValue) self.rejected = rejected.map(\.rawValue) self.consentedAt = consentedAt self.bannerVersion = bannerVersion self.userAgent = userAgent self.tcString = tcString } } // MARK: - Live Implementation /// URLSession-backed API client that communicates with the CMP API. public final class ConsentAPI: ConsentAPIProtocol, @unchecked Sendable { private let apiBase: URL private let session: URLSession private let decoder: JSONDecoder private let encoder: JSONEncoder // MARK: - Initialiser /// - Parameters: /// - apiBase: The base URL of the CMP API (e.g. `https://api.example.com`). /// - session: Defaults to `URLSession.shared`; inject a custom session in tests. public init(apiBase: URL, session: URLSession = .shared) { self.apiBase = apiBase self.session = session let dec = JSONDecoder() dec.dateDecodingStrategy = .iso8601 dec.keyDecodingStrategy = .convertFromSnakeCase self.decoder = dec let enc = JSONEncoder() enc.dateEncodingStrategy = .iso8601 enc.keyEncodingStrategy = .convertToSnakeCase self.encoder = enc } // MARK: - ConsentAPIProtocol public func fetchConfig(siteId: String) async throws -> ConsentConfig { let url = apiBase .appendingPathComponent("api/v1/config/sites") .appendingPathComponent(siteId) .appendingPathComponent("effective") var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(sdkUserAgent, forHTTPHeaderField: "User-Agent") let (data, response) = try await performRequest(request) try validateResponse(response) do { return try decoder.decode(ConsentConfig.self, from: data) } catch { throw ConsentAPIError.decodingFailure(error) } } public func postConsent(_ payload: ConsentPayload) async throws { let url = apiBase.appendingPathComponent("api/v1/consent") var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(sdkUserAgent, forHTTPHeaderField: "User-Agent") do { request.httpBody = try encoder.encode(payload) } catch { throw ConsentAPIError.decodingFailure(error) } let (_, response) = try await performRequest(request) try validateResponse(response) } // MARK: - Private Helpers private func performRequest(_ request: URLRequest) async throws -> (Data, URLResponse) { do { return try await session.data(for: request) } catch { throw ConsentAPIError.networkFailure(error) } } private func validateResponse(_ response: URLResponse) throws { guard let httpResponse = response as? HTTPURLResponse else { return } guard (200..<300).contains(httpResponse.statusCode) else { throw ConsentAPIError.unexpectedStatusCode(httpResponse.statusCode) } } private var sdkUserAgent: String { "ConsentOS-iOS/1.0.0 (Swift)" } }