Skip to content

Commit

Permalink
Merge pull request #54 from growthbook/remoteEval
Browse files Browse the repository at this point in the history
Added the new feature remote eval
  • Loading branch information
vazarkevych authored Apr 16, 2024
2 parents ded6501 + 3f720ae commit 5cb9410
Show file tree
Hide file tree
Showing 11 changed files with 140 additions and 33 deletions.
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

0 comments on commit 5cb9410

Please sign in to comment.