Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added the new feature remote eval #54

Merged
merged 1 commit into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions GrowthBook-IOS.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
849952572ADD6D66003BBCF7 /* EventModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849952562ADD6D66003BBCF7 /* EventModel.swift */; };
849952592ADD704A003BBCF7 /* EventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849952582ADD704A003BBCF7 /* EventHandler.swift */; };
84BC2E9D294A11F100289BC2 /* Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BC2E9C294A11F100289BC2 /* Crypto.swift */; };
84C55A322BCF08940058E669 /* RemoteEvalModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C55A312BCF08940058E669 /* RemoteEvalModel.swift */; };
84CDE32B2812F359008B3E6F /* GrowthBook.h in Headers */ = {isa = PBXBuildFile; fileRef = 84CDE32A2812F359008B3E6F /* GrowthBook.h */; settings = {ATTRIBUTES = (Public, ); }; };
84CDE3332812F454008B3E6F /* GrowthBookSDK.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843363F627F845EB0072BFDC /* GrowthBookSDK.swift */; };
84CDE3342812F454008B3E6F /* NetworkClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843363F827F845EB0072BFDC /* NetworkClient.swift */; };
Expand Down Expand Up @@ -117,6 +118,7 @@
849952562ADD6D66003BBCF7 /* EventModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventModel.swift; sourceTree = "<group>"; };
849952582ADD704A003BBCF7 /* EventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventHandler.swift; sourceTree = "<group>"; };
84BC2E9C294A11F100289BC2 /* Crypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Crypto.swift; sourceTree = "<group>"; };
84C55A312BCF08940058E669 /* RemoteEvalModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteEvalModel.swift; sourceTree = "<group>"; };
84CDE3282812F359008B3E6F /* GrowthBook.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = GrowthBook.framework; sourceTree = BUILT_PRODUCTS_DIR; };
84CDE32A2812F359008B3E6F /* GrowthBook.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GrowthBook.h; sourceTree = "<group>"; };
84F51E9627F419B000994D1C /* GrowthBook_IOS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GrowthBook_IOS.h; sourceTree = "<group>"; };
Expand Down Expand Up @@ -245,6 +247,7 @@
8433640827F845EB0072BFDC /* Feature.swift */,
8433640927F845EB0072BFDC /* Experiment.swift */,
8433640A27F845EB0072BFDC /* Context.swift */,
84C55A312BCF08940058E669 /* RemoteEvalModel.swift */,
);
path = Model;
sourceTree = "<group>";
Expand Down Expand Up @@ -479,6 +482,7 @@
84CDE3422812F454008B3E6F /* Experiment.swift in Sources */,
849952572ADD6D66003BBCF7 /* EventModel.swift in Sources */,
84CDE3432812F454008B3E6F /* Context.swift in Sources */,
84C55A322BCF08940058E669 /* RemoteEvalModel.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
8 changes: 4 additions & 4 deletions GrowthBookTests/GrowthBookSDKBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class GrowthBookSDKBuilderTests: XCTestCase {
backgroundSync: false).initializer()

XCTAssertTrue(sdkInstance.getGBContext().isEnabled)
XCTAssertTrue(sdkInstance.getGBContext().getApiHostURL() == expectedURL)
XCTAssertTrue(sdkInstance.getGBContext().getFeaturesURL() == expectedURL)
XCTAssertTrue(sdkInstance.getGBContext().encryptionKey == testKeyString)
XCTAssertFalse(sdkInstance.getGBContext().isQaMode)
XCTAssertTrue(sdkInstance.getGBContext().attributes == testAttributes)
Expand All @@ -37,7 +37,7 @@ class GrowthBookSDKBuilderTests: XCTestCase {
refreshHandler: nil, backgroundSync: false).setRefreshHandler(refreshHandler: { _ in }).setEnabled(isEnabled: false).setForcedVariations(forcedVariations: variations).setQAMode(isEnabled: true).initializer()

XCTAssertFalse(sdkInstance.getGBContext().isEnabled)
XCTAssertTrue(sdkInstance.getGBContext().getApiHostURL() == expectedURL)
XCTAssertTrue(sdkInstance.getGBContext().getFeaturesURL() == expectedURL)
XCTAssertTrue(sdkInstance.getGBContext().isQaMode)
XCTAssertTrue(sdkInstance.getGBContext().attributes == testAttributes)
XCTAssertTrue(sdkInstance.getGBContext().forcedVariations == JSON(variations))
Expand All @@ -56,7 +56,7 @@ class GrowthBookSDKBuilderTests: XCTestCase {
backgroundSync: false).setRefreshHandler(refreshHandler: { _ in }).setNetworkDispatcher(networkDispatcher: MockNetworkClient(successResponse: MockResponse().successResponse, error: nil)).setEnabled(isEnabled: false).setForcedVariations(forcedVariations: variations).setQAMode(isEnabled: true).initializer()

XCTAssertFalse(sdkInstance.getGBContext().isEnabled)
XCTAssertTrue(sdkInstance.getGBContext().getApiHostURL() == expectedURL)
XCTAssertTrue(sdkInstance.getGBContext().getFeaturesURL() == expectedURL)
XCTAssertTrue(sdkInstance.getGBContext().isQaMode)
XCTAssertTrue(sdkInstance.getGBContext().attributes == testAttributes)

Expand All @@ -75,7 +75,7 @@ class GrowthBookSDKBuilderTests: XCTestCase {
backgroundSync: false).setRefreshHandler(refreshHandler: { _ in }).setNetworkDispatcher(networkDispatcher: MockNetworkClient(successResponse: MockResponse().successResponseEncryptedFeatures, error: nil)).setEnabled(isEnabled: false).setForcedVariations(forcedVariations: variations).setQAMode(isEnabled: true).initializer()

XCTAssertFalse(sdkInstance.getGBContext().isEnabled)
XCTAssertTrue(sdkInstance.getGBContext().getApiHostURL() == expectedURL)
XCTAssertTrue(sdkInstance.getGBContext().getFeaturesURL() == expectedURL)
XCTAssertTrue(sdkInstance.getGBContext().isQaMode)
XCTAssertTrue(sdkInstance.getGBContext().attributes == testAttributes)
if !sdkInstance.getGBContext().features.isEmpty {
Expand Down
4 changes: 4 additions & 0 deletions GrowthBookTests/MockNetworkClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ class MockNetworkClient: NetworkProtocol {
errorResult(error)
}
}

func consumePOSTRequest(url: String, params: [String : Any], successResult: @escaping (Data) -> Void, errorResult: @escaping (any Error) -> Void) {

}
}

class MockResponse {
Expand Down
18 changes: 17 additions & 1 deletion Sources/CommonMain/Features/FeaturesDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,21 @@ class FeaturesDataSource {
fetchResult(.failure(error))
})
}


/// Executes API Call to fetch features and send data for remote eval
func fetchRemoteEval(apiUrl: String, params: RemoteEvalParams?, fetchResult: @escaping (Result<Data, Error>) -> Void) {
var payload: [String: Any] = [:]

if let params = params {
payload["attributes"] = params.attributes?.object
payload["forcedFeatures"] = params.forcedFeatures?.arrayObject
payload["forcedVariations"] = params.forcedVariations?.object
}

dispatcher.consumePOSTRequest(url: apiUrl, params: payload) { data in
fetchResult(.success(data))
} errorResult: { error in
fetchResult(.failure(error))
}
}
}
29 changes: 20 additions & 9 deletions Sources/CommonMain/Features/FeaturesViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class FeaturesViewModel {
}

/// Fetch Features
func fetchFeatures(apiUrl: String?) {
func fetchFeatures(apiUrl: String?, remoteEval: Bool = false, payload: RemoteEvalParams? = nil) {
// Check for cache data
if let json = manager.getData(fileName: Constants.featureCache) {
let decoder = JSONDecoder()
Expand All @@ -49,13 +49,25 @@ class FeaturesViewModel {
}

if let apiUrl = apiUrl {
dataSource.fetchFeatures(apiUrl: apiUrl) { result in
switch result {
case .success(let data):
self.prepareFeaturesData(data: data)
case .failure(let error):
self.delegate?.featuresFetchFailed(error: .failedToLoadData, isRemote: true)
logger.error("Failed get features: \(error.localizedDescription)")
if remoteEval {
dataSource.fetchRemoteEval(apiUrl: apiUrl, params: payload) { result in
switch result {
case .success(let data):
self.prepareFeaturesData(data: data)
case .failure(let error):
self.delegate?.featuresFetchFailed(error: .failedToLoadData, isRemote: true)
logger.error("Failed get features: \(error.localizedDescription)")
}
}
} else {
dataSource.fetchFeatures(apiUrl: apiUrl) { result in
switch result {
case .success(let data):
self.prepareFeaturesData(data: data)
case .failure(let error):
self.delegate?.featuresFetchFailed(error: .failedToLoadData, isRemote: true)
logger.error("Failed get features: \(error.localizedDescription)")
}
}
}
}
Expand All @@ -65,7 +77,6 @@ class FeaturesViewModel {
func prepareFeaturesData(data: Data) {
// Call Success Delegate with mention of data available with remote
let decoder = JSONDecoder()

if let jsonPetitions = try? decoder.decode(FeaturesDataModel.self, from: data) {
delegate?.featuresAPIModelSuccessfully(model: jsonPetitions)
if let features = jsonPetitions.features {
Expand Down
49 changes: 38 additions & 11 deletions Sources/CommonMain/GrowthBookSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public struct GrowthBookModel {
var forcedVariations: JSON?
var cacheDirectory: CacheDirectory = .applicationSupport
var backgroundSync: Bool
var remoteEval: Bool
}

/// GrowthBookBuilder - inItializer for GrowthBook SDK for Apps
Expand All @@ -36,18 +37,18 @@ public struct GrowthBookModel {
private var refreshHandler: CacheRefreshHandler?
private var networkDispatcher: NetworkProtocol = CoreNetworkClient()

@objc public init(apiHost: String? = nil, clientKey: String? = nil, encryptionKey: String? = nil, attributes: [String: Any], trackingCallback: @escaping TrackingCallback, refreshHandler: CacheRefreshHandler? = nil, backgroundSync: Bool = false) {
growthBookBuilderModel = GrowthBookModel(apiHost: apiHost, clientKey: clientKey, encryptionKey: encryptionKey, attributes: JSON(attributes), trackingClosure: trackingCallback, backgroundSync: backgroundSync)
@objc public init(apiHost: String? = nil, clientKey: String? = nil, encryptionKey: String? = nil, attributes: [String: Any], trackingCallback: @escaping TrackingCallback, refreshHandler: CacheRefreshHandler? = nil, backgroundSync: Bool = false, remoteEval: Bool = false) {
growthBookBuilderModel = GrowthBookModel(apiHost: apiHost, clientKey: clientKey, encryptionKey: encryptionKey, attributes: JSON(attributes), trackingClosure: trackingCallback, backgroundSync: backgroundSync, remoteEval: remoteEval)
self.refreshHandler = refreshHandler
}

@objc public init(features: Data, attributes: [String: Any], trackingCallback: @escaping TrackingCallback, refreshHandler: CacheRefreshHandler? = nil, backgroundSync: Bool) {
growthBookBuilderModel = GrowthBookModel(features: features, attributes: JSON(attributes), trackingClosure: trackingCallback, backgroundSync: backgroundSync)
@objc public init(features: Data, attributes: [String: Any], trackingCallback: @escaping TrackingCallback, refreshHandler: CacheRefreshHandler? = nil, backgroundSync: Bool, remoteEval: Bool = false) {
growthBookBuilderModel = GrowthBookModel(features: features, attributes: JSON(attributes), trackingClosure: trackingCallback, backgroundSync: backgroundSync, remoteEval: remoteEval)
self.refreshHandler = refreshHandler
}

init(apiHost: String, clientKey: String, encryptionKey: String? = nil, attributes: JSON, trackingCallback: @escaping TrackingCallback, refreshHandler: CacheRefreshHandler?, backgroundSync: Bool) {
growthBookBuilderModel = GrowthBookModel(apiHost: apiHost, clientKey: clientKey, encryptionKey: encryptionKey, attributes: JSON(attributes), trackingClosure: trackingCallback, backgroundSync: backgroundSync)
init(apiHost: String, clientKey: String, encryptionKey: String? = nil, attributes: JSON, trackingCallback: @escaping TrackingCallback, refreshHandler: CacheRefreshHandler?, backgroundSync: Bool, remoteEval: Bool = false) {
growthBookBuilderModel = GrowthBookModel(apiHost: apiHost, clientKey: clientKey, encryptionKey: encryptionKey, attributes: JSON(attributes), trackingClosure: trackingCallback, backgroundSync: backgroundSync, remoteEval: remoteEval)
self.refreshHandler = refreshHandler
}

Expand All @@ -70,7 +71,7 @@ public struct GrowthBookModel {
growthBookBuilderModel.logLevel = Logger.getLoggingLevel(from: level)
return self
}

@objc public func setForcedVariations(forcedVariations: [String: Int]) -> GrowthBookBuilder {
growthBookBuilderModel.forcedVariations = JSON(forcedVariations)
return self
Expand Down Expand Up @@ -101,7 +102,8 @@ public struct GrowthBookModel {
forcedVariations: growthBookBuilderModel.forcedVariations,
isQaMode: growthBookBuilderModel.isQaMode,
trackingClosure: growthBookBuilderModel.trackingClosure,
backgroundSync: growthBookBuilderModel.backgroundSync
backgroundSync: growthBookBuilderModel.backgroundSync,
remoteEval: growthBookBuilderModel.remoteEval
)
if let features = growthBookBuilderModel.features {
CachingManager.shared.saveContent(fileName: Constants.featureCache, content: features)
Expand All @@ -119,6 +121,7 @@ public struct GrowthBookModel {
private var networkDispatcher: NetworkProtocol
public var gbContext: Context
private var featureVM: FeaturesViewModel!
private var forcedFeatures: JSON = JSON()
private var attributeOverrides: JSON = JSON()

init(context: Context,
Expand Down Expand Up @@ -151,7 +154,11 @@ public struct GrowthBookModel {

/// Manually Refresh Cache
@objc public func refreshCache() {
featureVM.fetchFeatures(apiUrl: gbContext.getApiHostURL())
if gbContext.remoteEval {
refreshForRemoteEval()
} else {
featureVM.fetchFeatures(apiUrl: gbContext.getFeaturesURL())
}
}

/// This function removes all files and subdirectories within the designated cache directory, which is a specific subdirectory within the app's cache directory.
Expand All @@ -176,7 +183,6 @@ public struct GrowthBookModel {

@objc public func featuresFetchedSuccessfully(features: [String: Feature], isRemote: Bool) {
gbContext.features = features

if isRemote {
refreshHandler?(true)
}
Expand All @@ -195,6 +201,13 @@ public struct GrowthBookModel {
refreshHandler?(false)
}
}

/// If remote eval is enabled, send needed data to backend to proceed remote evaluation
@objc public func refreshForRemoteEval() {
if !gbContext.remoteEval { return }
let payload = RemoteEvalParams(attributes: gbContext.attributes, forcedFeatures: self.forcedFeatures, forcedVariations: gbContext.forcedVariations )
featureVM.fetchFeatures(apiUrl: gbContext.getRemoteEvalUrl(), remoteEval: gbContext.remoteEval, payload: payload)
}

/// The feature method takes a single string argument, which is the unique identifier for the feature and returns a FeatureResult object.
@objc public func evalFeature(id: String) -> FeatureResult {
Expand All @@ -210,6 +223,11 @@ public struct GrowthBookModel {
@objc public func run(experiment: Experiment) -> ExperimentResult {
return ExperimentEvaluator(attributeOverrides: attributeOverrides).evaluateExperiment(context: gbContext, experiment: experiment)
}

/// The setForcedFeatures method updates forced features
@objc public func setForcedFeatures(forcedFeatures: Any) {
self.forcedFeatures = JSON(forcedFeatures)
}

/// The setAttributes method replaces the Map of user attributes that are used to assign variations
@objc public func setAttributes(attributes: Any) {
Expand All @@ -219,7 +237,16 @@ public struct GrowthBookModel {

@objc public func setAttributeOverrides(overrides: Any) {
attributeOverrides = JSON(overrides)
refreshStickyBucketService()
if gbContext.stickyBucketService != nil {
refreshStickyBucketService()
}
refreshForRemoteEval()
}

/// The setForcedVariations method updates forced variations and makes API call if remote eval is enabled
@objc public func setForcedVariations(forcedVariations: Any) {
gbContext.forcedVariations = JSON(forcedVariations)
refreshForRemoteEval()
}

func featuresAPIModelSuccessfully(model: FeaturesDataModel) {
Expand Down
20 changes: 14 additions & 6 deletions Sources/CommonMain/Model/Context.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation

/// Defines the GrowthBook context.
@objc public class Context: NSObject {
/// api host
/// your api host
public let apiHost: String?
/// unique client key
public let clientKey: String?
Expand All @@ -13,7 +13,7 @@ import Foundation
/// Map of user attributes that are used to assign variations
public var attributes: JSON
/// Force specific experiments to always assign a specific variation (used for QA)
public let forcedVariations: JSON?
public var forcedVariations: JSON?
/// If true, random assignment is disabled and only explicitly forced variations are used.
public let isQaMode: Bool
/// A function that takes experiment and result as arguments.
Expand All @@ -26,8 +26,8 @@ import Foundation
public let stickyBucketService: StickyBucketServiceProtocol?

public var stickyBucketIdentifierAttributes: [String]?
public let remoteEval: Bool?

public let remoteEval: Bool
// Keys are unique identifiers for the features and the values are Feature objects.
// Feature definitions - To be pulled from API / Cache
var features: Features
Expand All @@ -45,7 +45,7 @@ import Foundation
trackingClosure: @escaping (Experiment, ExperimentResult) -> Void,
features: Features = [:],
backgroundSync: Bool = false,
remoteEval: Bool? = nil) {
remoteEval: Bool = false) {
self.apiHost = apiHost
self.clientKey = clientKey
self.encryptionKey = encryptionKey
Expand All @@ -62,14 +62,22 @@ import Foundation
self.remoteEval = remoteEval
}

@objc public func getApiHostURL() -> String? {
@objc public func getFeaturesURL() -> String? {
if let apiHost = apiHost, let clientKey = clientKey {
return "\(apiHost)/api/features/\(clientKey)"
} else {
return nil
}
}

@objc public func getRemoteEvalUrl() -> String? {
if let apiHost = apiHost, let clientKey = clientKey {
return "\(apiHost)/api/eval/\(clientKey)"
} else {
return nil
}
}

@objc public func getSSEUrl() -> String? {
if let apiHost = apiHost, let clientKey = clientKey {
return "\(apiHost)/sub/\(clientKey)"
Expand Down
2 changes: 1 addition & 1 deletion Sources/CommonMain/Model/Experiment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import Foundation
public let hashAttribute: String?
/// When using sticky bucketing, can be used as a fallback to assign variations
public let fallbackAttribute: String?

/// The hash version to use (default to `1`)
public let hashVersion: Float?
/// If true, sticky bucketing will be disabled for this experiment. (Note: sticky bucketing is only available if a StickyBucketingService is provided in the Context)
public let disableStickyBucketing: Bool?
Expand Down
7 changes: 7 additions & 0 deletions Sources/CommonMain/Model/RemoteEvalModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Foundation

struct RemoteEvalParams: Decodable {
let attributes: JSON?
let forcedFeatures: JSON?
let forcedVariations: JSON?
}
Loading
Loading