[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:
Daniel Arantes Loverde
2026-09-16 09:23:57 -03:00
parent d067791930
commit 2ba487a6e8
47 changed files with 4264 additions and 113 deletions

View File

@@ -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)
}
}
}