feat: initial public release
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.
This commit is contained in:
165
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentAPI.swift
Normal file
165
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentAPI.swift
Normal file
@@ -0,0 +1,165 @@
|
||||
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)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user