feat(app): integrate Atomenta Feature Control bootstrap, cache and resume refresh

This commit is contained in:
Daniel Arantes Loverde
2026-04-16 14:48:39 -03:00
parent f8d2fb8eb7
commit 7f7d414e6c
6 changed files with 433 additions and 1 deletions

View File

@@ -0,0 +1,287 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
struct FeatureControlRawFlag: Codable, Equatable {
let enabled: Bool
let variant: String
let payload: FeatureControlJSONValue?
let reason: String?
}
enum FeatureControlJSONValue: Codable, Equatable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: FeatureControlJSONValue])
case array([FeatureControlJSONValue])
case null
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([String: FeatureControlJSONValue].self) {
self = .object(value)
} else if let value = try? container.decode([FeatureControlJSONValue].self) {
self = .array(value)
} else {
self = .null
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let value): try container.encode(value)
case .number(let value): try container.encode(value)
case .bool(let value): try container.encode(value)
case .object(let value): try container.encode(value)
case .array(let value): try container.encode(value)
case .null: try container.encodeNil()
}
}
}
private struct FeatureControlBootstrapRequest: Codable {
struct Context: Codable {
let subjectType: String
let subjectId: String
let storeId: String?
let platform: String
let appVersion: String
let attributes: [String: String]
}
let environment: String
let keys: [String]
let context: Context
}
private struct FeatureControlBootstrapResponse: Codable {
let ok: Bool
let source: String?
let configVersion: Int
let evaluatedAt: String?
let flags: [String: FeatureFlagValue]
let raw: [String: FeatureControlRawFlag]
}
private struct FeatureControlExposureRequest: Codable {
struct Event: Codable {
let featureKey: String
let variant: String
let subjectType: String
let storeId: String?
}
let events: [Event]
}
private struct FeatureControlCacheEntry: Codable {
let expiresAtUnixMs: Int64
let snapshot: FeatureFlagsState
}
struct FeatureControlEvaluationContext {
let subjectType: String
let subjectId: String
let storeId: String?
let attributes: [String: String]
}
@MainActor
final class FeatureControlService {
static let shared = FeatureControlService()
private let session: URLSession
private let cacheTTL: TimeInterval
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let userDefaults: UserDefaults
private let defaultsPrefix = "feature-control.cache.v1."
init(
session: URLSession = .shared,
cacheTTL: TimeInterval = 60,
userDefaults: UserDefaults = .standard
) {
self.session = session
self.cacheTTL = cacheTTL
self.userDefaults = userDefaults
}
func evaluate(
context: FeatureControlEvaluationContext,
jwt: String?,
forceRefresh: Bool = false
) async -> FeatureFlagsState {
let key = storageKey(for: context)
if forceRefresh == false, let cached = loadFromCache(storageKey: key) {
return cached
}
let requestBody = FeatureControlBootstrapRequest(
environment: ApiConfig.featureControlEnvironment,
keys: featureKeys(),
context: .init(
subjectType: context.subjectType,
subjectId: context.subjectId,
storeId: context.storeId,
platform: platformName(),
appVersion: appVersion(),
attributes: context.attributes
)
)
do {
let response = try await performBootstrapRequest(body: requestBody, jwt: jwt)
let snapshot = FeatureFlagsState(
configVersion: response.configVersion,
evaluatedAt: response.evaluatedAt,
source: response.source ?? "live",
values: response.flags,
raw: response.raw
)
saveToCache(snapshot: snapshot, storageKey: key)
return snapshot
} catch {
if let cached = loadFromCache(storageKey: key) {
return FeatureFlagsState(
configVersion: cached.configVersion,
evaluatedAt: cached.evaluatedAt,
source: "cache_fallback",
values: cached.values,
raw: cached.raw
)
}
return FeatureFlagsState(source: "defaults")
}
}
func sendExposureEvents(snapshot: FeatureFlagsState, context: FeatureControlEvaluationContext, jwt: String?) async {
guard snapshot.raw.isEmpty == false else { return }
let events = snapshot.raw.compactMap { entry -> FeatureControlExposureRequest.Event? in
let key = entry.key
let value = entry.value
guard value.enabled || value.variant.lowercased() != "off" else { return nil }
return .init(
featureKey: key,
variant: value.variant,
subjectType: context.subjectType,
storeId: context.storeId
)
}
guard events.isEmpty == false else { return }
let batched = Array(events.prefix(100))
let payload = FeatureControlExposureRequest(events: batched)
guard let body = try? encoder.encode(payload) else { return }
var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/telemetry/exposure"))
request.httpMethod = "POST"
request.httpBody = body
request.timeoutInterval = 3
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let jwt, jwt.isEmpty == false {
request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
}
_ = try? await session.data(for: request)
}
private func performBootstrapRequest(body: FeatureControlBootstrapRequest, jwt: String?) async throws -> FeatureControlBootstrapResponse {
var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/bootstrap"))
request.httpMethod = "POST"
request.httpBody = try encoder.encode(body)
request.timeoutInterval = 3
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let jwt, jwt.isEmpty == false {
request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw NetworkError.invalidResponse
}
return try decoder.decode(FeatureControlBootstrapResponse.self, from: data)
}
private func storageKey(for context: FeatureControlEvaluationContext) -> String {
let tokens = [
ApiConfig.featureControlEnvironment,
context.subjectType,
context.subjectId,
context.storeId ?? "none",
platformName(),
appVersion(),
featureKeys().joined(separator: "|")
]
let base = tokens.joined(separator: "::")
.lowercased()
.replacingOccurrences(of: " ", with: "_")
return defaultsPrefix + base
}
private func saveToCache(snapshot: FeatureFlagsState, storageKey: String) {
let expiresAt = Int64((Date().timeIntervalSince1970 + cacheTTL) * 1000)
let entry = FeatureControlCacheEntry(expiresAtUnixMs: expiresAt, snapshot: snapshot)
guard let data = try? encoder.encode(entry) else { return }
userDefaults.set(data, forKey: storageKey)
}
private func loadFromCache(storageKey: String) -> FeatureFlagsState? {
guard let data = userDefaults.data(forKey: storageKey),
let entry = try? decoder.decode(FeatureControlCacheEntry.self, from: data) else {
return nil
}
let now = Int64(Date().timeIntervalSince1970 * 1000)
guard entry.expiresAtUnixMs > now else {
userDefaults.removeObject(forKey: storageKey)
return nil
}
return entry.snapshot
}
private func featureKeys() -> [String] {
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "fc.checkout_v2,fc.search_ranking_v3"
let items = raw
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0.isEmpty == false }
return items.isEmpty ? ["fc.checkout_v2"] : items
}
private func platformName() -> String {
#if os(Android)
return "android"
#else
return "ios"
#endif
}
private func appVersion() -> String {
#if canImport(UIKit) || canImport(AppKit)
let version = (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let version, version.isEmpty == false {
return version
}
#endif
return "0.0.0"
}
}