[sub-spm-optional-products] Add LCECryptoKit and LCFeatureControl as optional sub-SPM products
LCEssentials installs standalone; each sub target-depends on it so linking a sub's product always pulls LCEssentials in too, without the consumer having to declare it separately. - LCECryptoKit: internalized from the remote LCECryptoKitBinary git dependency (embedded token URL removed) into a local binaryTarget vendoring Frameworks/LCECryptoKit.xcframework. LCECryptoKitManager moved out of LCEssentials core into its own LCECryptoKitManager target/product; the no-op fallback for when the binary wasn't linked is gone (breaking change for existing consumers, see decisions/2026-09-15-sub-spm-optional-products.md). - LCFeatureControl: new product wrapping Atomenta's Feature Control API (flag evaluation with TTL cache + safe-degrade fallback to defaults, notifications inbox, batched exposure telemetry). 45 new tests.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
import LCEssentials
|
||||
|
||||
/// Protocol seam for DI, mirroring `FeatureControlEvaluating`.
|
||||
public protocol FeatureControlNotifying: Sendable {
|
||||
func list(status: FeatureControlNotificationStatus, limit: Int) async throws
|
||||
-> (items: [FeatureControlNotification], nextCursor: String?)
|
||||
func markRead(id: String) async throws
|
||||
func markAllRead() async throws
|
||||
}
|
||||
|
||||
/// JWT-only — the OpenAPI fragment declares `security: [bearerAuth]` for every
|
||||
/// notifications route, no module-token alternative. Configure `configuration.auth`
|
||||
/// with `FeatureControlBearerAuth`; anything else gets a `401` from the server,
|
||||
/// surfaced as `FeatureControlError.unauthorized`.
|
||||
public actor FeatureControlNotificationsClient: FeatureControlNotifying {
|
||||
private let configuration: FeatureControlConfiguration
|
||||
private let api: API
|
||||
|
||||
public init(configuration: FeatureControlConfiguration, api: API = .shared) {
|
||||
self.configuration = configuration
|
||||
self.api = api
|
||||
}
|
||||
|
||||
public func list(status: FeatureControlNotificationStatus = .all, limit: Int = 20) async throws
|
||||
-> (items: [FeatureControlNotification], nextCursor: String?) {
|
||||
var headers: [String: String] = [:]
|
||||
await configuration.auth.authorize(&headers)
|
||||
|
||||
let query = "?status=\(status.rawValue)&limit=\(limit)"
|
||||
do {
|
||||
let envelope: FeatureControlNotificationListEnvelope = try await api.request(
|
||||
url: configuration.baseURL + configuration.notificationsPath + query,
|
||||
method: .get,
|
||||
headers: headers,
|
||||
timeoutInterval: configuration.requestTimeout
|
||||
)
|
||||
return (envelope.result.items, envelope.result.nextCursor)
|
||||
} catch {
|
||||
throw FeatureControlErrorMapper.map(error)
|
||||
}
|
||||
}
|
||||
|
||||
public func markRead(id: String) async throws {
|
||||
var headers: [String: String] = [:]
|
||||
await configuration.auth.authorize(&headers)
|
||||
|
||||
let encodedId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
||||
do {
|
||||
let _: String = try await api.request(
|
||||
url: configuration.baseURL + configuration.notificationsPath + "/\(encodedId)/read",
|
||||
method: .post,
|
||||
headers: headers,
|
||||
timeoutInterval: configuration.requestTimeout
|
||||
)
|
||||
} catch {
|
||||
throw FeatureControlErrorMapper.map(error)
|
||||
}
|
||||
}
|
||||
|
||||
public func markAllRead() async throws {
|
||||
var headers: [String: String] = [:]
|
||||
await configuration.auth.authorize(&headers)
|
||||
|
||||
do {
|
||||
let _: String = try await api.request(
|
||||
url: configuration.baseURL + configuration.notificationsPath + "/read-all",
|
||||
method: .post,
|
||||
headers: headers,
|
||||
timeoutInterval: configuration.requestTimeout
|
||||
)
|
||||
} catch {
|
||||
throw FeatureControlErrorMapper.map(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user