ConsentOS — a privacy-first cookie consent management platform. Self-hosted, source-available alternative to OneTrust, Cookiebot, and CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant architecture with role-based access, configuration cascade (system → org → group → site → region), dark-pattern detection in the scanner, and a tamper-evident consent record audit trail. This is the initial public release. Prior development history is retained internally. See README.md for the feature list, architecture overview, and quick-start instructions. Licensed under the Elastic Licence 2.0 — self-host freely; do not resell as a managed service.
166 lines
5.4 KiB
Swift
166 lines
5.4 KiB
Swift
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)"
|
|
}
|
|
}
|