Revision control

Copy as Markdown

/* This Source Code Form is subject to the terms of the Mozilla
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
#if canImport(UIKit)
import UIKit
#endif
/// `Variables` provides a type safe key-value style interface to configure application features
///
/// The feature developer requests a typed value with a specific `key`. If the key is present, and
/// the value is of the correct type, then it is returned. If neither of these are true, then `nil`
/// is returned.
///
/// The values may be under experimental control, but if not, `nil` is returned. In this case, the app should
/// provide the default value.
///
/// ```
/// let variables = nimbus.getVariables("about_welcome")
///
/// let title = variables.getString("title") ?? "Welcome, oo vudge"
/// let numSections = variables.getInt("num-sections") ?? 2
/// let isEnabled = variables.getBool("isEnabled") ?? true
/// ```
///
/// This may become the basis of a generated-from-manifest solution.
public protocol Variables {
var resourceBundles: [Bundle] { get }
/// Finds a string typed value for this key. If none exists, `nil` is returned.
///
/// N.B. the `key` and type `String` should be listed in the experiment manifest.
func getString(_ key: String) -> String?
/// Find an array for this key, and returns all the strings in that array. If none exists, `nil`
/// is returned.
func getStringList(_ key: String) -> [String]?
/// Find a map for this key, and returns a map containing all the entries that have strings
/// as their values. If none exists, then `nil` is returned.
func getStringMap(_ key: String) -> [String: String]?
/// Returns the whole variables object as a string map
/// will return `nil` if it cannot be converted
/// - Note: This function will omit any variables that could not be converted to strings
/// - Returns: a `[String:String]` dictionary representing the whole variables object
func asStringMap() -> [String: String]?
/// Finds a integer typed value for this key. If none exists, `nil` is returned.
///
/// N.B. the `key` and type `Int` should be listed in the experiment manifest.
func getInt(_ key: String) -> Int?
/// Find an array for this key, and returns all the integers in that array. If none exists, `nil`
/// is returned.
func getIntList(_ key: String) -> [Int]?
/// Find a map for this key, and returns a map containing all the entries that have integers
/// as their values. If none exists, then `nil` is returned.
func getIntMap(_ key: String) -> [String: Int]?
/// Returns the whole variables object as an Int map
/// will return `nil` if it cannot be converted
/// - Note: This function will omit any variables that could not be converted to Ints
/// - Returns: a `[String:Int]` dictionary representing the whole variables object
func asIntMap() -> [String: Int]?
/// Finds a boolean typed value for this key. If none exists, `nil` is returned.
///
/// N.B. the `key` and type `String` should be listed in the experiment manifest.
func getBool(_ key: String) -> Bool?
/// Find an array for this key, and returns all the booleans in that array. If none exists, `nil`
/// is returned.
func getBoolList(_ key: String) -> [Bool]?
/// Find a map for this key, and returns a map containing all the entries that have booleans
/// as their values. If none exists, then `nil` is returned.
func getBoolMap(_ key: String) -> [String: Bool]?
/// Returns the whole variables object as a boolean map
/// will return `nil` if it cannot be converted
/// - Note: This function will omit any variables that could not be converted to booleans
/// - Returns: a `[String:Bool]` dictionary representing the whole variables object
func asBoolMap() -> [String: Bool]?
/// Uses `getString(key: String)` to find the name of a drawable resource. If no value for `key`
/// exists, or no resource named with that value exists, then `nil` is returned.
///
/// N.B. the `key` and type `Image` should be listed in the experiment manifest. The
/// names of the drawable resources should also be listed.
func getImage(_ key: String) -> UIImage?
/// Uses `getStringList(key: String)` to get a list of strings, then coerces the
/// strings in the list into Images. Values that cannot be coerced are omitted.
func getImageList(_ key: String) -> [UIImage]?
/// Uses `getStringList(key: String)` to get a list of strings, then coerces the
/// values into Images. Values that cannot be coerced are omitted.
func getImageMap(_ key: String) -> [String: UIImage]?
/// Uses `getString(key: String)` to find the name of a string resource. If a value exists, and
/// a string resource exists with that name, then returns the string from the resource. If no
/// such resource exists, then return the string value as the text.
///
/// For strings, this is almost always the right choice.
///
/// N.B. the `key` and type `LocalizedString` should be listed in the experiment manifest. The
/// names of the string resources should also be listed.
func getText(_ key: String) -> String?
/// Uses `getStringList(key: String)` to get a list of strings, then coerces the
/// strings in the list into localized text strings.
func getTextList(_ key: String) -> [String]?
/// Uses `getStringMap(key: String)` to get a map of strings, then coerces the
/// string values into localized text strings.
func getTextMap(_ key: String) -> [String: String]?
/// Gets a nested `JSONObject` value for this key, and creates a new `Variables` object. If
/// the value at the key is not a JSONObject, then return `nil`.
func getVariables(_ key: String) -> Variables?
/// Gets a list value for this key, and transforms all `JSONObject`s in the list into `Variables`.
/// If the value isn't a list, then returns `nil`. Items in the list that are not `JSONObject`s
/// are omitted from the final list.
func getVariablesList(_ key: String) -> [Variables]?
/// Gets a map value for this key, and transforms all `JSONObject`s that are values into `Variables`.
/// If the value isn't a `JSONObject`, then returns `nil`. Values in the map that are not `JSONObject`s
/// are omitted from the final map.
func getVariablesMap(_ key: String) -> [String: Variables]?
/// Returns the whole variables object as a variables map
/// will return `nil` if it cannot be converted
/// - Note: This function will omit any variables that could not be converted to a class representing variables
/// - Returns: a `[String:Variables]` dictionary representing the whole variables object
func asVariablesMap() -> [String: Variables]?
}
public extension Variables {
// This may be important when transforming in to a code generated object.
/// Get a `Variables` object for this key, and transforms it to a `T`. If this is not possible, then the
/// `transform` should return `nil`.
func getVariables<T>(_ key: String, transform: (Variables) -> T?) -> T? {
if let value = getVariables(key) {
return transform(value)
} else {
return nil
}
}
/// Uses `getVariablesList(key)` then transforms each `Variables` into a `T`.
/// If any item cannot be transformed, it is skipped.
func getVariablesList<T>(_ key: String, transform: (Variables) -> T?) -> [T]? {
return getVariablesList(key)?.compactMap(transform)
}
/// Uses `getVariablesMap(key)` then transforms each `Variables` value into a `T`.
/// If any value cannot be transformed, it is skipped.
func getVariablesMap<T>(_ key: String, transform: (Variables) -> T?) -> [String: T]? {
return getVariablesMap(key)?.compactMapValues(transform)
}
/// Uses `getString(key: String)` to find a string value for the given key, and coerce it into
/// the `Enum<T>`. If the value doesn't correspond to a variant of the type T, then `nil` is
/// returned.
func getEnum<T: RawRepresentable>(_ key: String) -> T? where T.RawValue == String {
if let string = getString(key) {
return asEnum(string)
} else {
return nil
}
}
/// Uses `getStringList(key: String)` to find a value that is a list of strings for the given key,
/// and coerce each item into an `Enum<T>`.
///
/// If the value doesn't correspond to a variant of the list, then `nil` is
/// returned.
///
/// Items of the list that are not underlying strings, or cannot be coerced into variants,
/// are omitted.
func getEnumList<T: RawRepresentable>(_ key: String) -> [T]? where T.RawValue == String {
return getStringList(key)?.compactMap(asEnum)
}
/// Uses `getStringMap(key: String)` to find a value that is a map of strings for the given key, and
/// coerces each value into an `Enum<T>`.
///
/// If the value doesn't correspond to a variant of the list, then `nil` is returned.
///
/// Values that are not underlying strings, or cannot be coerced into variants,
/// are omitted.
func getEnumMap<T: RawRepresentable>(_ key: String) -> [String: T]? where T.RawValue == String {
return getStringMap(key)?.compactMapValues(asEnum)
}
}
public extension Dictionary where Key == String {
func compactMapKeys<T>(_ transform: (String) -> T?) -> [T: Value] {
let pairs = keys.compactMap { (k: String) -> (T, Value)? in
guard let value = self[k],
let key = transform(k)
else {
return nil
}
return (key, value)
}
return [T: Value](uniqueKeysWithValues: pairs)
}
/// Convenience extension method for maps with `String` keys.
/// If a `String` key cannot be coerced into a variant of the given Enum, then the entry is
/// omitted.
///
/// This is useful in combination with `getVariablesMap(key, transform)`:
///
/// ```
/// let variables = nimbus.getVariables("menu-feature")
/// let menuItems: [MenuItemId: MenuItem] = variables
/// .getVariablesMap("items", ::toMenuItem)
/// ?.compactMapKeysAsEnums()
/// let menuItemOrder: [MenuItemId] = variables.getEnumList("item-order")
/// ```
func compactMapKeysAsEnums<T: RawRepresentable>() -> [T: Value] where T.RawValue == String {
return compactMapKeys(asEnum)
}
}
public extension Dictionary where Value == String {
/// Convenience extension method for maps with `String` values.
/// If a `String` value cannot be coerced into a variant of the given Enum, then the entry is
/// omitted.
func compactMapValuesAsEnums<T: RawRepresentable>() -> [Key: T] where T.RawValue == String {
return compactMapValues(asEnum)
}
}
private func asEnum<T: RawRepresentable>(_ string: String) -> T? where T.RawValue == String {
return T(rawValue: string)
}
protocol VariablesWithBundle: Variables {}
extension VariablesWithBundle {
func getImage(_ key: String) -> UIImage? {
return lookup(key, transform: asImage)
}
func getImageList(_ key: String) -> [UIImage]? {
return lookupList(key, transform: asImage)
}
func getImageMap(_ key: String) -> [String: UIImage]? {
return lookupMap(key, transform: asImage)
}
func getText(_ key: String) -> String? {
return lookup(key, transform: asLocalizedString)
}
func getTextList(_ key: String) -> [String]? {
return lookupList(key, transform: asLocalizedString)
}
func getTextMap(_ key: String) -> [String: String]? {
return lookupMap(key, transform: asLocalizedString)
}
private func lookup<T>(_ key: String, transform: (String) -> T?) -> T? {
guard let value = getString(key) else {
return nil
}
return transform(value)
}
private func lookupList<T>(_ key: String, transform: (String) -> T?) -> [T]? {
return getStringList(key)?.compactMap(transform)
}
private func lookupMap<T>(_ key: String, transform: (String) -> T?) -> [String: T]? {
return getStringMap(key)?.compactMapValues(transform)
}
/// Search through the resource bundles looking for an image of the given name.
///
/// If no image is found in any of the `resourceBundles`, then the `nil` is returned.
func asImage(name: String) -> UIImage? {
return resourceBundles.getImage(named: name)
}
/// Search through the resource bundles looking for localized strings with the given name.
/// If the `name` contains exactly one slash, it is split up and the first part of the string is used
/// as the `tableName` and the second the `key` in localized string lookup.
/// If no string is found in any of the `resourceBundles`, then the `name` is passed back unmodified.
func asLocalizedString(name: String) -> String? {
return resourceBundles.getString(named: name) ?? name
}
}
/// A thin wrapper around the JSON produced by the `get_feature_variables_json(feature_id)` call, useful
/// for configuring a feature, but without needing the developer to know about experiment specifics.
class JSONVariables: VariablesWithBundle {
private let json: [String: Any]
let resourceBundles: [Bundle]
init(with json: [String: Any], in bundles: [Bundle] = [Bundle.main]) {
self.json = json
resourceBundles = bundles
}
// These `get*` methods get values from the wrapped JSON object, and transform them using the
// `as*` methods.
func getString(_ key: String) -> String? {
return value(key)
}
func getStringList(_ key: String) -> [String]? {
return values(key)
}
func getStringMap(_ key: String) -> [String: String]? {
return valueMap(key)
}
func asStringMap() -> [String: String]? {
return nil
}
func getInt(_ key: String) -> Int? {
return value(key)
}
func getIntList(_ key: String) -> [Int]? {
return values(key)
}
func getIntMap(_ key: String) -> [String: Int]? {
return valueMap(key)
}
func asIntMap() -> [String: Int]? {
return nil
}
func getBool(_ key: String) -> Bool? {
return value(key)
}
func getBoolList(_ key: String) -> [Bool]? {
return values(key)
}
func getBoolMap(_ key: String) -> [String: Bool]? {
return valueMap(key)
}
func asBoolMap() -> [String: Bool]? {
return nil
}
// Methods used to get sub-objects. We immediately re-wrap an JSON object if it exists.
func getVariables(_ key: String) -> Variables? {
if let dictionary: [String: Any] = value(key) {
return JSONVariables(with: dictionary, in: resourceBundles)
} else {
return nil
}
}
func getVariablesList(_ key: String) -> [Variables]? {
return values(key)?.map { (dictionary: [String: Any]) in
JSONVariables(with: dictionary, in: resourceBundles)
}
}
func getVariablesMap(_ key: String) -> [String: Variables]? {
return valueMap(key)?.mapValues { (dictionary: [String: Any]) in
JSONVariables(with: dictionary, in: resourceBundles)
}
}
func asVariablesMap() -> [String: Variables]? {
return json.compactMapValues { value in
if let jsonMap = value as? [String: Any] {
return JSONVariables(with: jsonMap)
}
return nil
}
}
private func value<T>(_ key: String) -> T? {
return json[key] as? T
}
private func values<T>(_ key: String) -> [T]? {
guard let list = json[key] as? [Any] else {
return nil
}
return list.compactMap {
$0 as? T
}
}
private func valueMap<T>(_ key: String) -> [String: T]? {
guard let map = json[key] as? [String: Any] else {
return nil
}
return map.compactMapValues { $0 as? T }
}
}
// Another implementation of `Variables` may just return nil for everything.
public class NilVariables: Variables {
public static let instance = NilVariables()
public private(set) var resourceBundles: [Bundle] = [Bundle.main]
public func set(bundles: [Bundle]) {
resourceBundles = bundles
}
public func getString(_: String) -> String? {
return nil
}
public func getStringList(_: String) -> [String]? {
return nil
}
public func getStringMap(_: String) -> [String: String]? {
return nil
}
public func asStringMap() -> [String: String]? {
return nil
}
public func getInt(_: String) -> Int? {
return nil
}
public func getIntList(_: String) -> [Int]? {
return nil
}
public func getIntMap(_: String) -> [String: Int]? {
return nil
}
public func asIntMap() -> [String: Int]? {
return nil
}
public func getBool(_: String) -> Bool? {
return nil
}
public func getBoolList(_: String) -> [Bool]? {
return nil
}
public func getBoolMap(_: String) -> [String: Bool]? {
return nil
}
public func asBoolMap() -> [String: Bool]? {
return nil
}
public func getImage(_: String) -> UIImage? {
return nil
}
public func getImageList(_: String) -> [UIImage]? {
return nil
}
public func getImageMap(_: String) -> [String: UIImage]? {
return nil
}
public func getText(_: String) -> String? {
return nil
}
public func getTextList(_: String) -> [String]? {
return nil
}
public func getTextMap(_: String) -> [String: String]? {
return nil
}
public func getVariables(_: String) -> Variables? {
return nil
}
public func getVariablesList(_: String) -> [Variables]? {
return nil
}
public func getVariablesMap(_: String) -> [String: Variables]? {
return nil
}
public func asVariablesMap() -> [String: Variables]? {
return nil
}
}