import Foundation 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"] ?? "at.city.aguai,at.promo,at.ios.only,at.android.only,at.cupons" let items = raw .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { $0.isEmpty == false } return items.isEmpty ? ["at.ios.only"] : items } private func platformName() -> String { return "ios" } private func appVersion() -> String { let version = (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) if let version, version.isEmpty == false { return version } return "0.0.0" } }