Skip to content

Commit 0c92b2e

Browse files
author
Volodymyr Nazarkevych
committed
Merge branch 'main' into streaming-host-update
# Conflicts: # Sources/CommonMain/GrowthBookSDK.swift
2 parents c1b9ac1 + 4db1c3b commit 0c92b2e

File tree

8 files changed

+191
-69
lines changed

8 files changed

+191
-69
lines changed

GrowthBookTests/FeaturesViewModelTests.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
88
var isError: Bool = false
99
var hasFeatures: Bool = false
1010

11-
let cachingManager = CachingManager()
11+
let cachingManager: CachingLayer = CachingManager()
1212

1313
override func setUp() {
1414
super.setUp()
@@ -87,7 +87,8 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
8787

8888
let viewModel = FeaturesViewModel(delegate: self, dataSource: FeaturesDataSource(dispatcher: MockNetworkClient(successResponse: MockResponse().successResponseEncryptedFeatures, error: nil)), cachingManager: cachingManager)
8989

90-
viewModel.encryptionKey = "3tfeoyW0wlo47bDnbWDkxg=="
90+
let encryptionKey = "3tfeoyW0wlo47bDnbWDkxg=="
91+
viewModel.encryptionKey = encryptionKey
9192
viewModel.fetchFeatures(apiUrl: "")
9293

9394
let cachingManager: CachingLayer = CachingManager()
@@ -97,7 +98,10 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
9798
return
9899
}
99100

100-
if let _ = try? JSONDecoder().decode(Features.self, from: featureData) {
101+
let crypto: CryptoProtocol = Crypto()
102+
if let encryptedString = String(data: featureData, encoding: .utf8), crypto.getFeaturesFromEncryptedFeatures(encryptedString: encryptedString, encryptionKey: encryptionKey) != nil {
103+
XCTAssertTrue(true)
104+
} else if let _ = try? JSONDecoder().decode(Features.self, from: featureData) {
101105
XCTAssertTrue(true)
102106
} else {
103107
XCTFail()

GrowthBookTests/GrowthBookSDKBuilderTests.swift

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class GrowthBookSDKBuilderTests: XCTestCase {
1212
let testAttributes: JSON = JSON()
1313
let testKeyString = "Ns04T5n9+59rl2x3SlNHtQ=="
1414

15-
let cachingManager = CachingManager(apiKey: "4r23r324f23")
15+
let cachingManager: CachingLayer = CachingManager(apiKey: "4r23r324f23")
1616

1717
final class RefreshFlag: @unchecked Sendable {
1818
private let lock = NSLock()
@@ -281,4 +281,37 @@ class GrowthBookSDKBuilderTests: XCTestCase {
281281

282282
XCTAssertEqual(2, countTrackingCallback)
283283
}
284+
285+
func testAppendAttributes() throws {
286+
let sdkInstance = GrowthBookBuilder(apiHost: testApiHost,
287+
clientKey: testClientKey,
288+
attributes: [:],
289+
trackingCallback: { _, _ in },
290+
refreshHandler: nil,
291+
backgroundSync: false).initializer()
292+
293+
294+
sdkInstance.setAttributes(attributes: ["name": "Alice"])
295+
try sdkInstance.appendAttributes(attributes: ["age": 30])
296+
297+
let result = sdkInstance.gbContext.attributes
298+
XCTAssertEqual(result["name"].stringValue, "Alice")
299+
XCTAssertEqual(result["age"].intValue, 30)
300+
301+
302+
sdkInstance.setAttributes(attributes: ["user": ["id": 1, "name": "Alice"]])
303+
try sdkInstance.appendAttributes(attributes: ["user": ["name": "Bob", "age": 25]])
304+
305+
let user = sdkInstance.gbContext.attributes["user"]
306+
XCTAssertEqual(user["id"].intValue, 1)
307+
XCTAssertEqual(user["name"].stringValue, "Bob")
308+
XCTAssertEqual(user["age"].intValue, 25)
309+
310+
311+
sdkInstance.setAttributes(attributes: ["user": ["roles": ["admin", "editor"]]])
312+
try sdkInstance.appendAttributes(attributes: ["user": ["roles": ["viewer"]]])
313+
314+
let roles = sdkInstance.gbContext.attributes["user"]["roles"].arrayValue.map { $0.stringValue }
315+
XCTAssertEqual(roles, ["admin", "editor", "viewer"])
316+
}
284317
}

Sources/CommonMain/Caching/CachingManager.swift

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ import Foundation
22
import CommonCrypto
33

44
/// Interface for Caching Layer
5-
public protocol CachingLayer: AnyObject {
5+
@objc public protocol CachingLayer: AnyObject {
66
func saveContent(fileName: String, content: Data)
77
func getContent(fileName: String) -> Data?
88
func setCacheKey(_ key: String)
9+
func clearCache()
10+
func setSystemCacheDirectory(_ directory: CacheDirectory)
11+
func setCustomCachePath(_ path: String)
912
}
1013

1114
/// This is actual implementation of Caching Layer in iOS
@@ -35,14 +38,6 @@ public protocol CachingLayer: AnyObject {
3538
let key = hash.map { String(format: "%02x", $0) }.joined()
3639
return String(key.prefix(5))
3740
}
38-
39-
@objc func getData(fileName: String) -> Data? {
40-
return getContent(fileName: fileName)
41-
}
42-
43-
@objc func putData(fileName: String, content: Data) {
44-
saveContent(fileName: fileName, content: content)
45-
}
4641

4742
/// Set a custom cache saving directory
4843
@objc public func setCustomCachePath(_ path: String) {

Sources/CommonMain/Evaluators/FeatureEvaluator.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ class FeatureEvaluator {
3333
context.stackContext.evaluatedFeatures.insert(featureKey)
3434
context.stackContext.id = featureKey
3535

36+
defer {
37+
context.stackContext.evaluatedFeatures.remove(featureKey)
38+
}
39+
3640
if context.userContext.forcedFeatureValues?.dictionaryValue[featureKey] != nil {
3741
let value = context.userContext.forcedFeatureValues?[featureKey] ?? "nil"
3842
logger.info("Global override for forced feature with key: \(featureKey) and value \(value)")

Sources/CommonMain/Features/FeaturesViewModel.swift

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ class FeaturesViewModel {
1515
let dataSource: FeaturesDataSource
1616
var encryptionKey: String?
1717
/// Caching Manager
18-
let manager: CachingManager
18+
let manager: CachingLayer
1919

20-
init(delegate: FeaturesFlowDelegate, dataSource: FeaturesDataSource, cachingManager: CachingManager) {
20+
init(delegate: FeaturesFlowDelegate, dataSource: FeaturesDataSource, cachingManager: CachingLayer) {
2121
self.delegate = delegate
2222
self.dataSource = dataSource
2323
self.manager = cachingManager
@@ -40,37 +40,35 @@ class FeaturesViewModel {
4040
}
4141
}
4242

43-
private func fetchCachedFeatures() {
43+
private func fetchCachedFeatures(logging: Bool = false) {
4444
// Check for cache data
45-
if let json = manager.getData(fileName: Constants.featureCache) {
45+
if let data = manager.getContent(fileName: Constants.featureCache) {
4646
let decoder = JSONDecoder()
47-
if let features = try? decoder.decode(Features.self, from: json) {
47+
if let encryptedString = String(data: data, encoding: .utf8), let encryptionKey, !encryptionKey.isEmpty {
48+
let crypto: CryptoProtocol = Crypto()
49+
if let features = crypto.getFeaturesFromEncryptedFeatures(encryptedString: encryptedString, encryptionKey: encryptionKey) {
50+
delegate?.featuresFetchedSuccessfully(features: features, isRemote: false)
51+
} else {
52+
delegate?.featuresFetchFailed(error: .failedParsedEncryptedData, isRemote: false)
53+
if logging { logger.error("Failed get features from cached encrypted features") }
54+
}
55+
} else if let features = try? decoder.decode(Features.self, from: data) {
4856
// Call Success Delegate with mention of data available but its not remote
4957
delegate?.featuresFetchedSuccessfully(features: features, isRemote: false)
5058
} else {
5159
delegate?.featuresFetchFailed(error: .failedParsedData, isRemote: false)
60+
if logging { logger.error("Failed parse local data") }
5261
}
5362
} else {
5463
delegate?.featuresFetchFailed(error: .failedToLoadData, isRemote: false)
64+
if logging { logger.info("Cache directory is empty. Nothing to fetch.") }
5565
}
5666
}
5767

5868
/// Fetch Features
5969
func fetchFeatures(apiUrl: String?, remoteEval: Bool = false, payload: RemoteEvalParams? = nil) {
6070
// Check for cache data
61-
if let json = manager.getData(fileName: Constants.featureCache) {
62-
let decoder = JSONDecoder()
63-
if let features = try? decoder.decode(Features.self, from: json) {
64-
// Call Success Delegate with mention of data available but its not remote
65-
delegate?.featuresFetchedSuccessfully(features: features, isRemote: false)
66-
} else {
67-
delegate?.featuresFetchFailed(error: .failedParsedData, isRemote: false)
68-
logger.error("Failed parse local data")
69-
}
70-
} else {
71-
delegate?.featuresFetchFailed(error: .failedToLoadData, isRemote: false)
72-
logger.info("Cache directory is empty. Nothing to fetch.")
73-
}
71+
fetchCachedFeatures(logging: true)
7472

7573
if let apiUrl = apiUrl {
7674
if remoteEval {
@@ -111,8 +109,8 @@ class FeaturesViewModel {
111109
if let encryptionKey = encryptionKey, !encryptionKey.isEmpty {
112110
let crypto: CryptoProtocol = Crypto()
113111
if let features = crypto.getFeaturesFromEncryptedFeatures(encryptedString: encryptedString, encryptionKey: encryptionKey) {
114-
if let featureData = try? JSONEncoder().encode(features) {
115-
manager.putData(fileName: Constants.featureCache, content: featureData)
112+
if let featureData = encryptedString.data(using: .utf8) {
113+
manager.saveContent(fileName: Constants.featureCache, content: featureData)
116114
} else {
117115
logger.error("Failed encode features")
118116
}
@@ -129,7 +127,7 @@ class FeaturesViewModel {
129127
}
130128
} else if let features = jsonPetitions.features {
131129
if let featureData = try? JSONEncoder().encode(features) {
132-
manager.putData(fileName: Constants.featureCache, content: featureData)
130+
manager.saveContent(fileName: Constants.featureCache, content: featureData)
133131
}
134132
delegate?.featuresFetchedSuccessfully(features: features, isRemote: true)
135133
} else {
@@ -141,8 +139,8 @@ class FeaturesViewModel {
141139
if let encryptedSavedGroups = jsonPetitions.encryptedSavedGroups, !encryptedSavedGroups.isEmpty, let encryptionKey = encryptionKey, !encryptionKey.isEmpty {
142140
let crypto = Crypto()
143141
if let savedGroups = crypto.getSavedGroupsFromEncryptedFeatures(encryptedString: encryptedSavedGroups, encryptionKey: encryptionKey) {
144-
if let encryptedSavedGroups = try? JSONEncoder().encode(savedGroups) {
145-
manager.putData(fileName: Constants.savedGroupsCache, content: encryptedSavedGroups)
142+
if let encryptedSavedGroups = encryptedSavedGroups.data(using: .utf8) {
143+
manager.saveContent(fileName: Constants.savedGroupsCache, content: encryptedSavedGroups)
146144
} else {
147145
logger.error("Failed encode saved groups")
148146
}
@@ -154,7 +152,7 @@ class FeaturesViewModel {
154152
}
155153
} else if let savedGroups = jsonPetitions.savedGroups {
156154
if let savedGroupsData = try? JSONEncoder().encode(savedGroups) {
157-
manager.putData(fileName: Constants.savedGroupsCache, content: savedGroupsData)
155+
manager.saveContent(fileName: Constants.savedGroupsCache, content: savedGroupsData)
158156
}
159157
delegate?.savedGroupsFetchedSuccessfully(savedGroups: savedGroups, isRemote: true)
160158
}

Sources/CommonMain/GrowthBookSDK.swift

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ public struct GrowthBookModel {
2626
var stickyBucketService: StickyBucketServiceProtocol?
2727
var backgroundSync: Bool
2828
var remoteEval: Bool
29+
var apiRequestHeaders: [String: String]? = nil
30+
var streamingHostRequestHeaders: [String: String]? = nil
2931
}
3032

3133
/// GrowthBookBuilder - inItializer for GrowthBook SDK for Apps
@@ -39,23 +41,57 @@ public struct GrowthBookModel {
3941
private var refreshHandler: CacheRefreshHandler?
4042
private var networkDispatcher: NetworkProtocol = CoreNetworkClient()
4143

42-
private var cachingManager: CachingManager
44+
private var cachingManager: CachingLayer
4345

44-
@objc public init(apiHost: String? = nil, clientKey: String? = nil, encryptionKey: String? = nil, attributes: [String: Any], features: Data? = nil, trackingCallback: @escaping TrackingCallback, refreshHandler: CacheRefreshHandler? = nil, backgroundSync: Bool = false, remoteEval: Bool = false) {
45-
growthBookBuilderModel = GrowthBookModel(apiHost: apiHost, clientKey: clientKey, encryptionKey: encryptionKey, features: features, attributes: JSON(attributes), trackingClosure: trackingCallback, backgroundSync: backgroundSync, remoteEval: remoteEval)
46+
@objc public init(
47+
apiHost: String? = nil,
48+
clientKey: String? = nil,
49+
encryptionKey: String? = nil,
50+
attributes: [String: Any],
51+
features: Data? = nil,
52+
trackingCallback: @escaping TrackingCallback,
53+
refreshHandler: CacheRefreshHandler? = nil,
54+
backgroundSync: Bool = false,
55+
remoteEval: Bool = false,
56+
apiRequestHeaders: [String: String]? = nil,
57+
streamingHostRequestHeaders: [String: String]? = nil
58+
) {
59+
growthBookBuilderModel = GrowthBookModel(
60+
apiHost: apiHost,
61+
clientKey: clientKey,
62+
encryptionKey: encryptionKey,
63+
features: features,
64+
attributes: JSON(attributes),
65+
trackingClosure: trackingCallback,
66+
backgroundSync: backgroundSync,
67+
remoteEval: remoteEval,
68+
apiRequestHeaders: apiRequestHeaders,
69+
streamingHostRequestHeaders: streamingHostRequestHeaders)
4670
self.refreshHandler = refreshHandler
71+
self.networkDispatcher = CoreNetworkClient(
72+
apiRequestHeaders: apiRequestHeaders ?? [:],
73+
streamingHostRequestHeaders: streamingHostRequestHeaders ?? [:]
74+
)
4775
self.cachingManager = CachingManager(apiKey: clientKey)
4876
}
4977

50-
@objc public init(features: Data, attributes: [String: Any], trackingCallback: @escaping TrackingCallback, refreshHandler: CacheRefreshHandler? = nil, backgroundSync: Bool, remoteEval: Bool = false) {
51-
growthBookBuilderModel = GrowthBookModel(features: features, attributes: JSON(attributes), trackingClosure: trackingCallback, backgroundSync: backgroundSync, remoteEval: remoteEval)
78+
@objc public init(features: Data, attributes: [String: Any], trackingCallback: @escaping TrackingCallback, refreshHandler: CacheRefreshHandler? = nil, backgroundSync: Bool, remoteEval: Bool = false, apiRequestHeaders: [String: String]? = nil, streamingHostRequestHeaders: [String: String]? = nil) {
79+
growthBookBuilderModel = GrowthBookModel(features: features, attributes: JSON(attributes), trackingClosure: trackingCallback, backgroundSync: backgroundSync, remoteEval: remoteEval, apiRequestHeaders: apiRequestHeaders, streamingHostRequestHeaders: streamingHostRequestHeaders)
5280
self.refreshHandler = refreshHandler
81+
self.networkDispatcher = CoreNetworkClient(
82+
apiRequestHeaders: apiRequestHeaders ?? [:],
83+
streamingHostRequestHeaders: streamingHostRequestHeaders ?? [:]
84+
)
5385
self.cachingManager = CachingManager()
5486
}
5587

56-
init(apiHost: String, clientKey: String, encryptionKey: String? = nil, attributes: JSON, trackingCallback: @escaping TrackingCallback, refreshHandler: CacheRefreshHandler?, backgroundSync: Bool, remoteEval: Bool = false) {
57-
growthBookBuilderModel = GrowthBookModel(apiHost: apiHost, clientKey: clientKey, encryptionKey: encryptionKey, attributes: JSON(attributes), trackingClosure: trackingCallback, backgroundSync: backgroundSync, remoteEval: remoteEval)
88+
init(apiHost: String, clientKey: String, encryptionKey: String? = nil, attributes: JSON, trackingCallback: @escaping TrackingCallback, refreshHandler: CacheRefreshHandler?, backgroundSync: Bool, remoteEval: Bool = false, apiRequestHeaders: [String: String]? = nil, streamingHostRequestHeaders: [String: String]? = nil) {
89+
growthBookBuilderModel = GrowthBookModel(apiHost: apiHost, clientKey: clientKey, encryptionKey: encryptionKey, attributes: JSON(attributes), trackingClosure: trackingCallback, backgroundSync: backgroundSync, remoteEval: remoteEval, apiRequestHeaders: apiRequestHeaders, streamingHostRequestHeaders: streamingHostRequestHeaders)
5890
self.refreshHandler = refreshHandler
91+
self.networkDispatcher = CoreNetworkClient(
92+
apiRequestHeaders: apiRequestHeaders ?? [:],
93+
streamingHostRequestHeaders: streamingHostRequestHeaders ?? [:]
94+
)
5995
self.cachingManager = CachingManager(apiKey: clientKey)
6096
}
6197

@@ -71,6 +107,12 @@ public struct GrowthBookModel {
71107
return self
72108
}
73109

110+
/// Set Caching Manager - Caching Client for saving fetched features
111+
@objc public func setCachingManager(cachingManager: CachingLayer) -> GrowthBookBuilder {
112+
self.cachingManager = cachingManager
113+
return self
114+
}
115+
74116
@objc public func setStickyBucketService(stickyBucketService: StickyBucketServiceProtocol? = StickyBucketService()) -> GrowthBookBuilder {
75117
growthBookBuilderModel.stickyBucketService = stickyBucketService
76118
return self
@@ -155,15 +197,15 @@ public struct GrowthBookModel {
155197
private var attributeOverrides: JSON = JSON()
156198
private var savedGroupsValues: JSON?
157199
private var evalContext: EvalContext? = nil
158-
var cachingManager: CachingManager
200+
var cachingManager: CachingLayer
159201

160202
init(context: Context,
161203
refreshHandler: CacheRefreshHandler? = nil,
162204
logLevel: Level = .info,
163205
networkDispatcher: NetworkProtocol = CoreNetworkClient(),
164206
features: Features? = nil,
165207
savedGroups: JSON? = nil,
166-
cachingManager: CachingManager) {
208+
cachingManager: CachingLayer) {
167209
gbContext = context
168210
self.refreshHandler = refreshHandler
169211
self.networkDispatcher = networkDispatcher
@@ -300,6 +342,14 @@ public struct GrowthBookModel {
300342
refreshStickyBucketService()
301343
}
302344

345+
/// Merges the provided user attributes with the existing ones.
346+
/// - Throws: `SwiftyJSON.Error.wrongType` if the top-level JSON types differ
347+
@objc public func appendAttributes(attributes: Any) throws {
348+
let updatedAttributes = try gbContext.attributes.merged(with: JSON(attributes))
349+
gbContext.attributes = updatedAttributes
350+
refreshStickyBucketService()
351+
}
352+
303353
@objc public func setAttributeOverrides(overrides: Any) {
304354
attributeOverrides = JSON(overrides)
305355
if gbContext.stickyBucketService != nil {
@@ -314,6 +364,20 @@ public struct GrowthBookModel {
314364
refreshForRemoteEval()
315365
}
316366

367+
/// Updates API request headers for dynamic header management
368+
@objc public func updateApiRequestHeaders(_ headers: [String: String]) {
369+
if let networkClient = networkDispatcher as? CoreNetworkClient {
370+
networkClient.apiRequestHeaders = headers
371+
}
372+
}
373+
374+
/// Updates streaming host request headers for SSE connections
375+
@objc public func updateStreamingHostRequestHeaders(_ headers: [String: String]) {
376+
if let networkClient = networkDispatcher as? CoreNetworkClient {
377+
networkClient.streamingHostRequestHeaders = headers
378+
}
379+
}
380+
317381
@objc func featuresAPIModelSuccessfully(model: FeaturesDataModel) {
318382
refreshStickyBucketService(model)
319383
}

0 commit comments

Comments
 (0)