[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:
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright (c) 2025 Loverde Co.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
@@ -18,43 +18,41 @@
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
|
||||
import Foundation
|
||||
|
||||
#if canImport(LCECryptoKit)
|
||||
import LCEssentials
|
||||
import LCECryptoKit
|
||||
|
||||
public final class LCECryptoKitManager {
|
||||
|
||||
|
||||
private let hashKey: String
|
||||
|
||||
|
||||
public init() {
|
||||
self.hashKey = ""
|
||||
}
|
||||
|
||||
public init(privateKey: String){
|
||||
|
||||
public init(privateKey: String) {
|
||||
self.hashKey = privateKey
|
||||
}
|
||||
|
||||
|
||||
public static func generateKey() -> String {
|
||||
LCECryptoKit.generateRandomAESKeyString()
|
||||
}
|
||||
|
||||
|
||||
public func encodeTP(email: String, password: String) -> String? {
|
||||
return LCECryptoKit.encodeSeed(email: email, password: password)
|
||||
}
|
||||
|
||||
|
||||
public func decodeOTP(_ otpHash: String) -> String? {
|
||||
return LCECryptoKit.decodeSeed(otpKey: otpHash)
|
||||
}
|
||||
|
||||
|
||||
// MARK: Need hashKey to decode
|
||||
|
||||
|
||||
public func encodeOTPWithKey(email: String, password: String) -> String? {
|
||||
return LCECryptoKit.encodeSeed(email: email, password: password, hashKey: self.hashKey)
|
||||
}
|
||||
|
||||
|
||||
public func decodeOTPWithKey(_ otpHash: String) -> Bool {
|
||||
LCECryptoKit.decodeSeed(otpKey: otpHash, hashKey: self.hashKey)
|
||||
}
|
||||
@@ -81,60 +79,3 @@ public final class LCECryptoKitManager {
|
||||
LCECryptoKit.otpDecode(otpEncoded)
|
||||
}
|
||||
}
|
||||
#else
|
||||
|
||||
public final class LCECryptoKitManager {
|
||||
|
||||
private let hashKey: String
|
||||
|
||||
public init() {
|
||||
self.hashKey = ""
|
||||
}
|
||||
|
||||
public init(privateKey: String){
|
||||
self.hashKey = privateKey
|
||||
}
|
||||
|
||||
public static func generateKey() -> String {
|
||||
""
|
||||
}
|
||||
|
||||
public func encodeTP(email: String, password: String) -> String? {
|
||||
nil
|
||||
}
|
||||
|
||||
public func decodeOTP(_ otpHash: String) -> String? {
|
||||
nil
|
||||
}
|
||||
|
||||
public func encodeOTPWithKey(email: String, password: String) -> String? {
|
||||
nil
|
||||
}
|
||||
|
||||
public func decodeOTPWithKey(_ otpHash: String) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
// MARK: - Salted/Iterated/Peppered Login (atomenta-cryptokit-pepper-refactor-sdd.md)
|
||||
|
||||
public static func generateSalt() -> String {
|
||||
""
|
||||
}
|
||||
|
||||
public static func computeClientHash(email: String, password: String, salt: String) -> String {
|
||||
""
|
||||
}
|
||||
|
||||
public static func computeLoginBearerToken(userId: String, clientHash: String) -> String? {
|
||||
nil
|
||||
}
|
||||
|
||||
public static func otpEncode(_ plainText: String) -> String? {
|
||||
nil
|
||||
}
|
||||
|
||||
public static func otpDecode(_ otpEncoded: String) -> String? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
39
Sources/LCFeatureControl/FeatureControlAuthorizing.swift
Normal file
39
Sources/LCFeatureControl/FeatureControlAuthorizing.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
|
||||
/// Pluggable auth for outgoing Feature Control requests. No `Atomenta-Token` type
|
||||
/// exists here on purpose — that module token must never be embedded in a
|
||||
/// customer-facing app (see FC-060 §1 in Atomenta's `docs/feature-control/`).
|
||||
/// A consumer who insists on it does so explicitly via `FeatureControlHeaderAuth`.
|
||||
public protocol FeatureControlAuthorizing: Sendable {
|
||||
func authorize(_ headers: inout [String: String]) async
|
||||
}
|
||||
|
||||
/// For internal/admin apps hitting Atomenta directly with a panel-role JWT.
|
||||
public struct FeatureControlBearerAuth: FeatureControlAuthorizing {
|
||||
private let tokenProvider: @Sendable () async -> String?
|
||||
|
||||
public init(tokenProvider: @escaping @Sendable () async -> String?) {
|
||||
self.tokenProvider = tokenProvider
|
||||
}
|
||||
|
||||
public func authorize(_ headers: inout [String: String]) async {
|
||||
guard let token = await tokenProvider() else { return }
|
||||
headers["Authorization"] = "Bearer \(token)"
|
||||
}
|
||||
}
|
||||
|
||||
/// For a customer app calling its own BFF, which enforces its own auth. Adds
|
||||
/// exactly the given headers — never synthesizes an `Authorization` header.
|
||||
public struct FeatureControlHeaderAuth: FeatureControlAuthorizing {
|
||||
private let headers: [String: String]
|
||||
|
||||
public init(headers: [String: String]) {
|
||||
self.headers = headers
|
||||
}
|
||||
|
||||
public func authorize(_ headers: inout [String: String]) async {
|
||||
for (key, value) in self.headers {
|
||||
headers[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
46
Sources/LCFeatureControl/FeatureControlCache.swift
Normal file
46
Sources/LCFeatureControl/FeatureControlCache.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
/// Identifies a cached evaluation — FC-060 §5: "environment + subjectId + keys(sorted) +
|
||||
/// platform + appVersion". `sortedKeys` means caller `keys` order never affects hits.
|
||||
struct FeatureControlCacheKey: Hashable, Sendable {
|
||||
let environment: String
|
||||
let subjectId: String
|
||||
let sortedKeys: [String]
|
||||
let platform: String?
|
||||
let appVersion: String?
|
||||
}
|
||||
|
||||
/// In-memory-only TTL cache (no disk persistence — see SDD §2 Non-Goals). The clock is
|
||||
/// injectable so tests can force expiry deterministically instead of sleeping.
|
||||
actor FeatureControlCache {
|
||||
private struct Entry {
|
||||
let snapshot: FeatureControlSnapshot
|
||||
let storedAt: Date
|
||||
let ttl: TimeInterval
|
||||
}
|
||||
|
||||
private var entries: [FeatureControlCacheKey: Entry] = [:]
|
||||
private let now: @Sendable () -> Date
|
||||
|
||||
init(now: @escaping @Sendable () -> Date = { Date() }) {
|
||||
self.now = now
|
||||
}
|
||||
|
||||
/// `allowStale: true` returns an expired entry rather than `nil` — used by the
|
||||
/// safe-degrade fallback path (SDD §4.4/§4.5).
|
||||
func get(_ key: FeatureControlCacheKey, allowStale: Bool) -> FeatureControlSnapshot? {
|
||||
guard let entry = entries[key] else { return nil }
|
||||
let age = now().timeIntervalSince(entry.storedAt)
|
||||
if age <= entry.ttl { return entry.snapshot }
|
||||
return allowStale ? entry.snapshot : nil
|
||||
}
|
||||
|
||||
/// Always overwrites — a fresh fetch's `configVersion` simply replaces whatever was there.
|
||||
func set(_ key: FeatureControlCacheKey, snapshot: FeatureControlSnapshot, ttl: TimeInterval) {
|
||||
entries[key] = Entry(snapshot: snapshot, storedAt: now(), ttl: ttl)
|
||||
}
|
||||
|
||||
func invalidateAll() {
|
||||
entries.removeAll()
|
||||
}
|
||||
}
|
||||
33
Sources/LCFeatureControl/FeatureControlConfiguration.swift
Normal file
33
Sources/LCFeatureControl/FeatureControlConfiguration.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
/// Wiring for one Atomenta Feature Control module deployment.
|
||||
public struct FeatureControlConfiguration: Sendable {
|
||||
public var baseURL: String
|
||||
public var evaluatePath: String
|
||||
public var notificationsPath: String
|
||||
public var telemetryPath: String
|
||||
public var environment: String
|
||||
public var auth: any FeatureControlAuthorizing
|
||||
/// In-memory cache TTL. FC-060 §5 recommends 30–60s client-side.
|
||||
public var cacheTTL: TimeInterval
|
||||
/// FC-060 §5 recommends 1.5–3s at the BFF; same budget applies here.
|
||||
public var requestTimeout: TimeInterval
|
||||
|
||||
public init(baseURL: String,
|
||||
environment: String,
|
||||
auth: any FeatureControlAuthorizing,
|
||||
evaluatePath: String = "/api/feature-control/evaluate",
|
||||
notificationsPath: String = "/api/feature-control/notifications",
|
||||
telemetryPath: String = "/api/feature-control/telemetry/exposure",
|
||||
cacheTTL: TimeInterval = 45,
|
||||
requestTimeout: TimeInterval = 3) {
|
||||
self.baseURL = baseURL
|
||||
self.environment = environment
|
||||
self.auth = auth
|
||||
self.evaluatePath = evaluatePath
|
||||
self.notificationsPath = notificationsPath
|
||||
self.telemetryPath = telemetryPath
|
||||
self.cacheTTL = cacheTTL
|
||||
self.requestTimeout = requestTimeout
|
||||
}
|
||||
}
|
||||
30
Sources/LCFeatureControl/FeatureControlContext.swift
Normal file
30
Sources/LCFeatureControl/FeatureControlContext.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
/// Who/what a flag evaluation is for — mirrors Atomenta's `context.subjectType` enum.
|
||||
public enum FeatureControlSubjectType: String, Codable, Sendable {
|
||||
case user, customer, store, anonymous
|
||||
}
|
||||
|
||||
/// Evaluation context sent as `context` in `POST /api/feature-control/evaluate`.
|
||||
public struct FeatureControlContext: Encodable, Sendable, Equatable {
|
||||
public var subjectType: FeatureControlSubjectType
|
||||
public var subjectId: String
|
||||
public var storeId: String?
|
||||
public var platform: String?
|
||||
public var appVersion: String?
|
||||
public var attributes: [String: String]?
|
||||
|
||||
public init(subjectType: FeatureControlSubjectType,
|
||||
subjectId: String,
|
||||
storeId: String? = nil,
|
||||
platform: String? = nil,
|
||||
appVersion: String? = nil,
|
||||
attributes: [String: String]? = nil) {
|
||||
self.subjectType = subjectType
|
||||
self.subjectId = subjectId
|
||||
self.storeId = storeId
|
||||
self.platform = platform
|
||||
self.appVersion = appVersion
|
||||
self.attributes = attributes
|
||||
}
|
||||
}
|
||||
18
Sources/LCFeatureControl/FeatureControlDateParsing.swift
Normal file
18
Sources/LCFeatureControl/FeatureControlDateParsing.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
/// Parses the ISO-8601 timestamps Atomenta sends (`2026-04-16T12:00:00.000Z`, with
|
||||
/// milliseconds). `LCEssentials.API`'s internal `JSONDecoder` uses the default
|
||||
/// (`.deferredToDate`, numeric epoch) strategy, so `Date` fields on wire models
|
||||
/// decode the raw string manually instead of relying on `Decodable`'s default
|
||||
/// date handling.
|
||||
enum FeatureControlDateParsing {
|
||||
static func parse(_ string: String) -> Date? {
|
||||
let withFractional = ISO8601DateFormatter()
|
||||
withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = withFractional.date(from: string) { return date }
|
||||
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
return plain.date(from: string)
|
||||
}
|
||||
}
|
||||
51
Sources/LCFeatureControl/FeatureControlError.swift
Normal file
51
Sources/LCFeatureControl/FeatureControlError.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
import LCEssentials
|
||||
|
||||
/// Explicit failure for callers that want to see it (`evaluateOrThrow`, the
|
||||
/// notifications client). The safe `evaluate(...)` path never surfaces this —
|
||||
/// it degrades to cache/defaults instead (FC-060 §4).
|
||||
public enum FeatureControlError: Error, Sendable, Equatable {
|
||||
/// 400 `FEATURE_CONTROL_CONTEXT_INVALID`
|
||||
case invalidContext(code: String)
|
||||
/// 401 `…MISSING_AUTH` / `…INVALID_MODULE_TOKEN`
|
||||
case unauthorized(code: String)
|
||||
/// 403 `…PANEL_ROLE_REQUIRED` / `…INSUFFICIENT_PERMISSIONS`
|
||||
case forbidden(code: String)
|
||||
/// 429 `FEATURE_CONTROL_RATE_LIMIT`
|
||||
case rateLimited(code: String)
|
||||
/// Any other non-2xx status.
|
||||
case server(code: String, status: Int)
|
||||
/// Decoding failure, or any error not raised by `LCEssentials.API`'s HTTP path.
|
||||
case transport(message: String)
|
||||
}
|
||||
|
||||
enum FeatureControlErrorMapper {
|
||||
/// `LCEssentials.API` throws an `NSError` (domain `LCEssentials.DEFAULT_ERROR_DOMAIN`,
|
||||
/// `code` = HTTP status, `localizedFailureReason` = pretty-printed body) for non-2xx
|
||||
/// responses, or a bridged `DecodingError`/`URLError` for anything else. Only the
|
||||
/// former carries a real HTTP status to map.
|
||||
static func map(_ error: Error) -> FeatureControlError {
|
||||
let nsError = error as NSError
|
||||
guard nsError.domain == LCEssentials.DEFAULT_ERROR_DOMAIN else {
|
||||
return .transport(message: nsError.localizedDescription)
|
||||
}
|
||||
|
||||
let status = nsError.code
|
||||
let bodyCode = extractCode(from: nsError.localizedFailureReason) ?? "UNKNOWN"
|
||||
switch status {
|
||||
case 400: return .invalidContext(code: bodyCode)
|
||||
case 401: return .unauthorized(code: bodyCode)
|
||||
case 403: return .forbidden(code: bodyCode)
|
||||
case 429: return .rateLimited(code: bodyCode)
|
||||
default: return .server(code: bodyCode, status: status)
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractCode(from prettyJSON: String?) -> String? {
|
||||
guard let prettyJSON,
|
||||
let data = prettyJSON.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let code = object["code"] as? String else { return nil }
|
||||
return code
|
||||
}
|
||||
}
|
||||
28
Sources/LCFeatureControl/FeatureControlExposureEvent.swift
Normal file
28
Sources/LCFeatureControl/FeatureControlExposureEvent.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
/// One exposure event for `POST /api/feature-control/telemetry/exposure`.
|
||||
public struct FeatureControlExposureEvent: Encodable, Sendable, Equatable {
|
||||
public let featureKey: String
|
||||
public let variant: String?
|
||||
public let subjectType: FeatureControlSubjectType
|
||||
public let storeId: String?
|
||||
|
||||
public init(featureKey: String, variant: String?, subjectType: FeatureControlSubjectType, storeId: String?) {
|
||||
self.featureKey = featureKey
|
||||
self.variant = variant
|
||||
self.subjectType = subjectType
|
||||
self.storeId = storeId
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body — server caps `events` at 100 (`maxItems: 100`); batching into that
|
||||
/// limit is the caller's (`FeatureControlManager`'s) job, not this type's.
|
||||
struct FeatureControlExposureBatchBody: Encodable, Sendable {
|
||||
let events: [FeatureControlExposureEvent]
|
||||
}
|
||||
|
||||
/// Wire envelope for the telemetry response — `{error, code, result: {count}}`.
|
||||
struct FeatureControlExposureBatchEnvelope: Decodable, Sendable {
|
||||
let error: Bool
|
||||
let code: String?
|
||||
}
|
||||
61
Sources/LCFeatureControl/FeatureControlFlag.swift
Normal file
61
Sources/LCFeatureControl/FeatureControlFlag.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// One evaluated flag, as returned inside `EvaluateResponse.result.flags[key]`.
|
||||
public struct FeatureControlFlag: Decodable, Sendable, Equatable {
|
||||
public let enabled: Bool
|
||||
public let variant: String?
|
||||
public let payload: FeatureControlJSON?
|
||||
public let reason: String?
|
||||
|
||||
public init(enabled: Bool, variant: String?, payload: FeatureControlJSON?, reason: String?) {
|
||||
self.enabled = enabled
|
||||
self.variant = variant
|
||||
self.payload = payload
|
||||
self.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
/// `EvaluateResponse.result` — the batch evaluation result for a set of keys.
|
||||
public struct FeatureControlSnapshot: Sendable, Equatable {
|
||||
public let evaluatedAt: Date
|
||||
public let configVersion: Int
|
||||
public let flags: [String: FeatureControlFlag]
|
||||
|
||||
public init(evaluatedAt: Date, configVersion: Int, flags: [String: FeatureControlFlag]) {
|
||||
self.evaluatedAt = evaluatedAt
|
||||
self.configVersion = configVersion
|
||||
self.flags = flags
|
||||
}
|
||||
}
|
||||
|
||||
extension FeatureControlSnapshot: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case evaluatedAt, configVersion, flags
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let rawDate = try container.decode(String.self, forKey: .evaluatedAt)
|
||||
guard let date = FeatureControlDateParsing.parse(rawDate) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .evaluatedAt, in: container,
|
||||
debugDescription: "Unrecognized date format: \(rawDate)")
|
||||
}
|
||||
self.evaluatedAt = date
|
||||
self.configVersion = try container.decode(Int.self, forKey: .configVersion)
|
||||
self.flags = try container.decode([String: FeatureControlFlag].self, forKey: .flags)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire envelope for `POST /api/feature-control/evaluate` — `{error, code, result}`.
|
||||
struct FeatureControlEvaluateEnvelope: Decodable, Sendable {
|
||||
let error: Bool
|
||||
let code: String?
|
||||
let result: FeatureControlSnapshot
|
||||
}
|
||||
|
||||
/// Request body for `POST /api/feature-control/evaluate`.
|
||||
struct FeatureControlEvaluateRequestBody: Encodable, Sendable {
|
||||
let environment: String
|
||||
let keys: [String]
|
||||
let context: FeatureControlContext
|
||||
}
|
||||
33
Sources/LCFeatureControl/FeatureControlJSON.swift
Normal file
33
Sources/LCFeatureControl/FeatureControlJSON.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
/// Minimal "any JSON" box for a flag's `payload`, which the backend declares as
|
||||
/// `additionalProperties: true` (arbitrary shape). No force operations — an
|
||||
/// unrecognized shape throws a `DecodingError`, it never crashes.
|
||||
public indirect enum FeatureControlJSON: Decodable, Sendable, Equatable {
|
||||
case null
|
||||
case bool(Bool)
|
||||
case number(Double)
|
||||
case string(String)
|
||||
case array([FeatureControlJSON])
|
||||
case object([String: FeatureControlJSON])
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self = .null
|
||||
} else if let value = try? container.decode(Bool.self) {
|
||||
self = .bool(value)
|
||||
} else if let value = try? container.decode(Double.self) {
|
||||
self = .number(value)
|
||||
} else if let value = try? container.decode(String.self) {
|
||||
self = .string(value)
|
||||
} else if let value = try? container.decode([FeatureControlJSON].self) {
|
||||
self = .array(value)
|
||||
} else if let value = try? container.decode([String: FeatureControlJSON].self) {
|
||||
self = .object(value)
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(in: container,
|
||||
debugDescription: "Unsupported JSON value for FeatureControlJSON")
|
||||
}
|
||||
}
|
||||
}
|
||||
123
Sources/LCFeatureControl/FeatureControlManager.swift
Normal file
123
Sources/LCFeatureControl/FeatureControlManager.swift
Normal file
@@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
import LCEssentials
|
||||
|
||||
/// Protocol seam for DI — ViewModels/Interactors depend on this, never on the
|
||||
/// concrete actor, so a mock can stand in for tests.
|
||||
public protocol FeatureControlEvaluating: Sendable {
|
||||
/// Never throws. Fresh fetch → stale cache → app-supplied `defaults`, in that
|
||||
/// order (FC-060 §4 "nunca bloquear fluxo crítico"). Use this everywhere a
|
||||
/// flag gates real product behaviour.
|
||||
func evaluate(keys: [String], context: FeatureControlContext) async -> FeatureControlSnapshot
|
||||
/// Same fetch, but surfaces the real failure. For admin/debug tooling only.
|
||||
func evaluateOrThrow(keys: [String], context: FeatureControlContext) async throws -> FeatureControlSnapshot
|
||||
/// Drops every cached entry. Call on user/store change, or app foreground if desired —
|
||||
/// this type does not observe app lifecycle itself (SDD §2 Non-Goals).
|
||||
func invalidateCache() async
|
||||
}
|
||||
|
||||
public actor FeatureControlManager: FeatureControlEvaluating {
|
||||
private let configuration: FeatureControlConfiguration
|
||||
private let api: API
|
||||
private let defaults: [String: FeatureControlFlag]
|
||||
private let cache: FeatureControlCache
|
||||
private var exposureBuffer: [FeatureControlExposureEvent] = []
|
||||
|
||||
/// `configVersion` sentinel returned when no network response and no cache exist —
|
||||
/// distinguishes "never evaluated" from any real server value (server versions are ≥ 0).
|
||||
public static let unresolvedConfigVersion = -1
|
||||
|
||||
public init(configuration: FeatureControlConfiguration,
|
||||
api: API = .shared,
|
||||
defaults: [String: FeatureControlFlag] = [:],
|
||||
now: @escaping @Sendable () -> Date = { Date() }) {
|
||||
self.configuration = configuration
|
||||
self.api = api
|
||||
self.defaults = defaults
|
||||
self.cache = FeatureControlCache(now: now)
|
||||
}
|
||||
|
||||
public func evaluate(keys: [String], context: FeatureControlContext) async -> FeatureControlSnapshot {
|
||||
do {
|
||||
return try await evaluateOrThrow(keys: keys, context: context)
|
||||
} catch {
|
||||
let key = cacheKey(keys: keys, context: context)
|
||||
if let stale = await cache.get(key, allowStale: true) { return stale }
|
||||
return FeatureControlSnapshot(evaluatedAt: Date(),
|
||||
configVersion: Self.unresolvedConfigVersion,
|
||||
flags: defaults)
|
||||
}
|
||||
}
|
||||
|
||||
public func evaluateOrThrow(keys: [String], context: FeatureControlContext) async throws -> FeatureControlSnapshot {
|
||||
let key = cacheKey(keys: keys, context: context)
|
||||
if let fresh = await cache.get(key, allowStale: false) { return fresh }
|
||||
|
||||
var headers: [String: String] = [:]
|
||||
await configuration.auth.authorize(&headers)
|
||||
|
||||
let body = jsonBody(FeatureControlEvaluateRequestBody(
|
||||
environment: configuration.environment, keys: keys, context: context))
|
||||
|
||||
do {
|
||||
let envelope: FeatureControlEvaluateEnvelope = try await api.request(
|
||||
url: configuration.baseURL + configuration.evaluatePath,
|
||||
method: .post,
|
||||
body: body,
|
||||
headers: headers,
|
||||
timeoutInterval: configuration.requestTimeout
|
||||
)
|
||||
await cache.set(key, snapshot: envelope.result, ttl: configuration.cacheTTL)
|
||||
return envelope.result
|
||||
} catch {
|
||||
throw FeatureControlErrorMapper.map(error)
|
||||
}
|
||||
}
|
||||
|
||||
public func invalidateCache() async {
|
||||
await cache.invalidateAll()
|
||||
}
|
||||
|
||||
private func cacheKey(keys: [String], context: FeatureControlContext) -> FeatureControlCacheKey {
|
||||
FeatureControlCacheKey(environment: configuration.environment,
|
||||
subjectId: context.subjectId,
|
||||
sortedKeys: keys.sorted(),
|
||||
platform: context.platform,
|
||||
appVersion: context.appVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Exposure telemetry
|
||||
|
||||
extension FeatureControlManager {
|
||||
/// Buffers locally. Nothing is sent until `flushExposures()` is called.
|
||||
public func recordExposure(_ event: FeatureControlExposureEvent) {
|
||||
exposureBuffer.append(event)
|
||||
}
|
||||
|
||||
/// Best-effort — batches of ≤100 (server `maxItems: 100`); a failed batch is
|
||||
/// dropped, never retried indefinitely (not a critical-path operation).
|
||||
public func flushExposures() async {
|
||||
guard !exposureBuffer.isEmpty else { return }
|
||||
let events = exposureBuffer
|
||||
exposureBuffer.removeAll()
|
||||
|
||||
for batch in events.lce_featureControlChunked(into: 100) {
|
||||
var headers: [String: String] = [:]
|
||||
await configuration.auth.authorize(&headers)
|
||||
let body = jsonBody(FeatureControlExposureBatchBody(events: batch))
|
||||
_ = try? await api.request(
|
||||
url: configuration.baseURL + configuration.telemetryPath,
|
||||
method: .post,
|
||||
body: body,
|
||||
headers: headers,
|
||||
timeoutInterval: configuration.requestTimeout
|
||||
) as FeatureControlExposureBatchEnvelope
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
func lce_featureControlChunked(into size: Int) -> [[Element]] {
|
||||
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
|
||||
}
|
||||
}
|
||||
65
Sources/LCFeatureControl/FeatureControlNotification.swift
Normal file
65
Sources/LCFeatureControl/FeatureControlNotification.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
/// `GET /api/feature-control/notifications` `status` query filter.
|
||||
public enum FeatureControlNotificationStatus: String, Sendable {
|
||||
case all, unread
|
||||
}
|
||||
|
||||
/// One item from `NotificationListItem`.
|
||||
public struct FeatureControlNotification: Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let body: String
|
||||
public let severity: String
|
||||
public let createdAt: Date
|
||||
public let read: Bool
|
||||
public let ctaLabel: String?
|
||||
public let ctaUrl: String?
|
||||
|
||||
public init(id: String, title: String, body: String, severity: String,
|
||||
createdAt: Date, read: Bool, ctaLabel: String?, ctaUrl: String?) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.body = body
|
||||
self.severity = severity
|
||||
self.createdAt = createdAt
|
||||
self.read = read
|
||||
self.ctaLabel = ctaLabel
|
||||
self.ctaUrl = ctaUrl
|
||||
}
|
||||
}
|
||||
|
||||
extension FeatureControlNotification: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, title, body, severity, createdAt, read, ctaLabel, ctaUrl
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let rawDate = try container.decode(String.self, forKey: .createdAt)
|
||||
guard let date = FeatureControlDateParsing.parse(rawDate) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .createdAt, in: container,
|
||||
debugDescription: "Unrecognized date format: \(rawDate)")
|
||||
}
|
||||
self.id = try container.decode(String.self, forKey: .id)
|
||||
self.title = try container.decode(String.self, forKey: .title)
|
||||
self.body = try container.decode(String.self, forKey: .body)
|
||||
self.severity = try container.decode(String.self, forKey: .severity)
|
||||
self.createdAt = date
|
||||
self.read = try container.decode(Bool.self, forKey: .read)
|
||||
self.ctaLabel = try container.decodeIfPresent(String.self, forKey: .ctaLabel)
|
||||
self.ctaUrl = try container.decodeIfPresent(String.self, forKey: .ctaUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/feature-control/notifications` `result` shape.
|
||||
struct FeatureControlNotificationListResult: Decodable, Sendable {
|
||||
let items: [FeatureControlNotification]
|
||||
let nextCursor: String?
|
||||
}
|
||||
|
||||
/// Wire envelope for the notifications list — `{error, result}`.
|
||||
struct FeatureControlNotificationListEnvelope: Decodable, Sendable {
|
||||
let error: Bool
|
||||
let result: FeatureControlNotificationListResult
|
||||
}
|
||||
@@ -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