diff --git a/Sources/Statsig/InternalStore.swift b/Sources/Statsig/InternalStore.swift index c2b7594..100e7b8 100644 --- a/Sources/Statsig/InternalStore.swift +++ b/Sources/Statsig/InternalStore.swift @@ -26,6 +26,7 @@ struct StatsigValuesCache { var gates: [String: [String: Any]]? = nil var configs: [String: [String: Any]]? = nil var layers: [String: [String: Any]]? = nil + var paramStores: [String: [String: Any]]? = nil var hashUsed: String? = nil var sdkKey: String var options: StatsigOptions @@ -37,6 +38,7 @@ struct StatsigValuesCache { gates = userCache[InternalStore.gatesKey] as? [String: [String: Any]] configs = userCache[InternalStore.configsKey] as? [String: [String: Any]] layers = userCache[InternalStore.layerConfigsKey] as? [String: [String: Any]] + paramStores = userCache[InternalStore.paramStoresKey] as? [String: [String: Any]] hashUsed = userCache[InternalStore.hashUsedKey] as? String } } @@ -100,13 +102,33 @@ struct StatsigValuesCache { return Layer( client: client, name: layerName, - configObj: configObj, evalDetails: getEvaluationDetails(.Recognized) + configObj: configObj, + evalDetails: getEvaluationDetails(.Recognized) ) } print("[Statsig]: The layer with name \(layerName) does not exist. Returning an empty Layer.") return createUnfoundLayer(client, layerName) } + + func getParamStore(_ client: StatsigClient?, _ storeName: String) -> ParameterStore { + guard let stores = paramStores else { + print("[Statsig]: Failed to get parameter store with name \(storeName). Returning an empty ParameterStore.") + return createUnfoundParamStore(client, storeName) + } + + if let config = stores[storeName] ?? stores[storeName.hashSpecName(hashUsed)] { + return ParameterStore( + name: storeName, + evaluationDetails: getEvaluationDetails(.Recognized), + client: client, + configuration: config + ) + } + + print("[Statsig]: The parameter store with name \(storeName) does not exist. Returning an empty ParameterStore.") + return createUnfoundParamStore(client, storeName) + } func getStickyExperiment(_ expName: String) -> [String: Any]? { let expNameHash = expName.hashSpecName(hashUsed) @@ -159,6 +181,7 @@ struct StatsigValuesCache { cache[InternalStore.gatesKey] = values[InternalStore.gatesKey] cache[InternalStore.configsKey] = values[InternalStore.configsKey] cache[InternalStore.layerConfigsKey] = values[InternalStore.layerConfigsKey] + cache[InternalStore.paramStoresKey] = values[InternalStore.paramStoresKey] cache[InternalStore.lcutKey] = Time.parse(values[InternalStore.lcutKey]) cache[InternalStore.evalTimeKey] = Time.now() cache[InternalStore.userHashKey] = userHash @@ -336,6 +359,10 @@ struct StatsigValuesCache { evalDetails: getEvaluationDetails(.Unrecognized) ) } + + private func createUnfoundParamStore(_ client: StatsigClient?, _ name: String) -> ParameterStore { + ParameterStore(name: name, evaluationDetails: getEvaluationDetails(.Unrecognized)) + } } class InternalStore { @@ -353,6 +380,7 @@ class InternalStore { static let configsKey = "dynamic_configs" static let stickyExpKey = "sticky_experiments" static let layerConfigsKey = "layer_configs" + static let paramStoresKey = "param_stores" static let lcutKey = "time" static let evalTimeKey = "evaluation_time" static let userHashKey = "user_hash" @@ -453,6 +481,12 @@ class InternalStore { ) }) } + + func getParamStore(client: StatsigClient?, forName storeName: String) -> ParameterStore { + storeQueue.sync { + return cache.getParamStore(client, storeName) + } + } func finalizeValues(completion: (() -> Void)? = nil) { storeQueue.async(flags: .barrier) { [weak self] in diff --git a/Sources/Statsig/Layer.swift b/Sources/Statsig/Layer.swift index cea0c86..7faece7 100644 --- a/Sources/Statsig/Layer.swift +++ b/Sources/Statsig/Layer.swift @@ -45,7 +45,7 @@ public struct Layer: ConfigProtocol { public let allocatedExperimentName: String /** - (For debug purposes) Why did Statsig return this DynamicConfig + (For debug purposes) Why did Statsig return this Layer */ public let evaluationDetails: EvaluationDetails diff --git a/Sources/Statsig/ParameterStore.swift b/Sources/Statsig/ParameterStore.swift new file mode 100644 index 0000000..cb91f84 --- /dev/null +++ b/Sources/Statsig/ParameterStore.swift @@ -0,0 +1,180 @@ +import Foundation + +typealias ParamStoreConfiguration = [String: [String: Any]] + +fileprivate struct RefType { + static let staticValue = "static" + static let gate = "gate" + static let dynamicConfig = "dynamic_config" + static let experiment = "experiment" + static let layer = "layer" +} + +fileprivate struct ParamKeys { + static let paramType = "param_type" + static let refType = "ref_type" + + // Gate + static let gateName = "gate_name" + static let passValue = "pass_value" + static let failValue = "fail_value" + + // Static Value + static let value = "value" + + // Dynamic Config / Experiment / Layer + static let paramName = "param_name" + static let configName = "config_name" + static let experimentName = "experiment_name" + static let layerName = "layer_name" +} + +public struct ParameterStore { + /** + The name used to retrieve this ParameterStore. + */ + public let name: String + + /** + (For debug purposes) Why did Statsig return this ParameterStore + */ + public let evaluationDetails: EvaluationDetails + + internal let configuration: ParamStoreConfiguration + weak internal var client: StatsigClient? + + internal init( + name: String, + evaluationDetails: EvaluationDetails, + client: StatsigClient? = nil, + configuration: [String: Any] = [:] + ) { + self.name = name + self.evaluationDetails = evaluationDetails + self.client = client + self.configuration = configuration as? ParamStoreConfiguration ?? ParamStoreConfiguration() + } + + /** + Get the value for the given key. If the value cannot be found, or is found to have a different type than the defaultValue, the defaultValue will be returned. + If a valid value is found, a layer exposure event will be fired. + + Parameters: + - forKey: The key of parameter being fetched + - defaultValue: The fallback value if the key cannot be found + */ + public func getValue( + forKey paramName: String, + defaultValue: T + ) -> T { + if configuration.isEmpty { + return defaultValue + } + + guard + let client = client, + let param = configuration[paramName], + let refType = param[ParamKeys.refType] as? String, + let paramType = param[ParamKeys.paramType] as? String, + getTypeOf(defaultValue) == paramType + else { + return defaultValue + } + + switch refType { + case RefType.staticValue: + return getMappedStaticValue(param, defaultValue) + + case RefType.gate: + return getMappedGateValue(client, param, defaultValue) + + case RefType.dynamicConfig: + return getMappedDynamicConfigValue(client, param, defaultValue) + + case RefType.experiment: + return getMappedExperimentValue(client, param, defaultValue) + + case RefType.layer: + return getMappedLayerValue(client, param, defaultValue) + + default: + return defaultValue + } + } +} + +fileprivate func getMappedStaticValue( + _ param: [String: Any], + _ defaultValue: T +) -> T { + return param[ParamKeys.value] as? T ?? defaultValue +} + + +fileprivate func getMappedGateValue( + _ client: StatsigClient, + _ param: [String: Any], + _ defaultValue: T +) -> T { + guard + let gateName = param[ParamKeys.gateName] as? String, + let passValue = param[ParamKeys.passValue] as? T, + let failValue = param[ParamKeys.failValue] as? T + else { + return defaultValue + } + + let gate = client.getFeatureGate(gateName) + return gate.value ? passValue : failValue +} + + +fileprivate func getMappedDynamicConfigValue( + _ client: StatsigClient, + _ param: [String: Any], + _ defaultValue: T +) -> T { + guard + let configName = param[ParamKeys.configName] as? String, + let paramName = param[ParamKeys.paramName] as? String + else { + return defaultValue + } + + let config = client.getConfig(configName) + return config.getValue(forKey: paramName, defaultValue: defaultValue) +} + + +fileprivate func getMappedExperimentValue( + _ client: StatsigClient, + _ param: [String: Any], + _ defaultValue: T +) -> T { + guard + let experimentName = param[ParamKeys.experimentName] as? String, + let paramName = param[ParamKeys.paramName] as? String + else { + return defaultValue + } + + let experiment = client.getExperiment(experimentName) + return experiment.getValue(forKey: paramName, defaultValue: defaultValue) +} + + +fileprivate func getMappedLayerValue( + _ client: StatsigClient, + _ param: [String: Any], + _ defaultValue: T +) -> T { + guard + let layerName = param[ParamKeys.layerName] as? String, + let paramName = param[ParamKeys.paramName] as? String + else { + return defaultValue + } + + let layer = client.getLayer(layerName) + return layer.getValue(forKey: paramName, defaultValue: defaultValue) +} diff --git a/Sources/Statsig/Statsig.swift b/Sources/Statsig/Statsig.swift index fd2db2d..41acef0 100644 --- a/Sources/Statsig/Statsig.swift +++ b/Sources/Statsig/Statsig.swift @@ -251,6 +251,24 @@ public class Statsig { public static func getLayerWithExposureLoggingDisabled(_ layerName: String, keepDeviceValue: Bool = false) -> Layer { return getLayerImpl(layerName, keepDeviceValue: keepDeviceValue, withExposures: false, functionName: funcName()) } + + /** + + */ + public static func getParameterStore(_ storeName: String) -> ParameterStore { + let functionName = funcName() + var result = ParameterStore(name: storeName, evaluationDetails: .uninitialized()) + + errorBoundary.capture(functionName) { + guard let client = client else { + print("[Statsig]: \(getUnstartedErrorMessage(functionName)). Returning a dummy ParameterStore that will only return default values.") + return + } + + result = client.getParameterStore(storeName) + } + return result + } /** Logs an exposure event for the given layer parameter. Only required if a related getLayerWithExposureLoggingDisabled call has been made. diff --git a/Sources/Statsig/StatsigClient.swift b/Sources/Statsig/StatsigClient.swift index ddccd1c..f3053e7 100644 --- a/Sources/Statsig/StatsigClient.swift +++ b/Sources/Statsig/StatsigClient.swift @@ -537,6 +537,21 @@ extension StatsigClient { } } +// MARK: Parameter Stores + +extension StatsigClient { + public func getParameterStore(_ storeName: String) -> ParameterStore { + logger.incrementNonExposedCheck(storeName) + + let store = store.getParamStore(client: self, forName: storeName) + if let cb = statsigOptions.evaluationCallback { + cb(.parameterStore(store)) + } + + return store + } +} + // MARK: Log Event extension StatsigClient { diff --git a/Sources/Statsig/StatsigDynamicConfigValue.swift b/Sources/Statsig/StatsigDynamicConfigValue.swift index 3e75470..a628ae6 100644 --- a/Sources/Statsig/StatsigDynamicConfigValue.swift +++ b/Sources/Statsig/StatsigDynamicConfigValue.swift @@ -17,3 +17,31 @@ extension Int: StatsigDynamicConfigValue {} extension String: StatsigDynamicConfigValue {} extension String?: StatsigDynamicConfigValue {} + + +fileprivate struct TypeString { + static let boolean = "boolean" + static let number = "number" + static let string = "string" + static let array = "array" + static let object = "object" +} + +func getTypeOf(_ value: T) -> String? { + switch value { + case is Bool: + return TypeString.boolean + case is Int, is Double: + return TypeString.number + case is String, is Optional: + return TypeString.string + case is Array: + return TypeString.array + case is Dictionary: + return TypeString.object + default: + return nil + } +} + + diff --git a/Sources/Statsig/StatsigOptions.swift b/Sources/Statsig/StatsigOptions.swift index f17e87e..61b3683 100644 --- a/Sources/Statsig/StatsigOptions.swift +++ b/Sources/Statsig/StatsigOptions.swift @@ -11,6 +11,7 @@ public class StatsigOptions { case config (DynamicConfig) case experiment (DynamicConfig) case layer (Layer) + case parameterStore (ParameterStore) } /** diff --git a/Tests/StatsigTests/EvaluationCallbackSpec.swift b/Tests/StatsigTests/EvaluationCallbackSpec.swift index 2d3a219..9f122e0 100644 --- a/Tests/StatsigTests/EvaluationCallbackSpec.swift +++ b/Tests/StatsigTests/EvaluationCallbackSpec.swift @@ -54,6 +54,8 @@ final class EvaluationCallbackSpec: BaseSpec { experimentNameResult = exp.name case .layer(let layer): layerNameResult = layer.name + case .parameterStore(let _paramStore): + break } }