Skip to content

Lightweight, extensible solution for configuring values on iOS

License

Notifications You must be signed in to change notification settings

tevelee/Configs

Repository files navigation

Configs

Overview

It's a very lightweight library, that aims to solve configurability when building apps. Any time when there are static values related to business logic, we usually have the need to configure them from a remote end. For example: backend service URLs, feature toggles, API keys or just static values when building new features: trying different color options, numeric values, or strings.

This framework allows you to do that during runtime, handing out your apps to testers and stakeholders so they can configure and tweak various options on the same binary.

The system was designed with extensibility and flexibility in mind, so that the library remains minimalistic, while the configuration options are endless with custom data types and rendering methods.

The Config library has a dependency of Tweaks which makes it easy to tweak values during runtime.

Usage

Defining a flag

let secretNewInProgressFeatureFlag = ConfigDefinition(defaultValue: false)
        .when(.deviceType(is: .iPad), return: false)
        .userDefaults(key: "secret_new_feature")
        .firebaseRemoteConfig(key: "secret_new_feature")
        .tweak(category: "Feature Flags", section: "In progress features", name: "Secret New Feature Enabled")

It's a pretty complex statement, so let's break it down line by line. Each line represents a value provider, that either returns a value or not. The way to interpret is the following: the first line is the default (fallback) value, when all providers fail to return anything. The interpretation goes bottom-up, the lastlt defined provider gets evaluated first, asking whether the tweaking system returns value. If no tweaks are set, it goes to the next provider, asking whether this value is registered in Firebase Remote Config. If not, then it turns to user defaults. In the next line there is an example for a conditional option, return a static value when some condition is met.

ConfigDefinition is a struct, it is passed around as value type.

As you might noticed, the definition is generic. In the example above we defined a config definition with a value of Bool, but in fact it could be any definition as long as the type conforms to Configurable protocol. A few other examples:

let backendBaseUrl = ConfigDefinition(defaultValue: "https://production.url")
let numberOfFreeItems = ConfigDefinition(defaultValue: 1)
let valuesForChart = ConfigDefinition(defaultValue: [1, 2, 3])
let chartOffset = ConfigDefinition<Double?>(defaultValue: nil)

It supports all sorts of types. Optionals are quite useful in many scenarios.

Of course, ConfigRepository is an ObservableObject so you can use it easily in SwiftUI as well. See the example app for more details.

Registering it to the system

The ConfigRepository holds all definitions and is able to extract values from them. It's a singleton for ease of access, as we don't expect to be more than one repository during the app lifecycle.

let configRepo = ConfigRepository.shared
configRepo.add(secretNewInProgressFeatureFlag)

Obtaining values

You can use a subscript with the (generic) definition, which returns a value of the generic type. In this case, a Bool.

print(configRepo[secretNewInProgressFeatureFlag])

For Bool values, there is a complementary accessor method as well for clearer callsites:

print(configRepo.isOn(secretNewInProgressFeatureFlag))

Listening to changes

It is as easy to listen values as reading them. In this case the block returns new values anytime the computed value changes.

configRepo.listen(to: secretNewInProgressFeatureFlag) { value in
    print("Value changed: \(value)")
}

Anytime when any provider changes it's values. Most common one is tweaks, but other values such as user defaults or third party providers (like Firebase Remote Configuration) can change over time as well. This obviously needs it to hold Equatable values.

Custom values

Let's say you have a custom type that you'd like to configure. All you need is to conform to the Configurable protocol, and you're all set.

enum ABValues: String, Configurable {
    case a, b
}

If you also conform to CaseIterable and Tweakable, you have tweak support. If you conform to StorableInUserDefaults, you have userDefaults support as well. All these have default implementations, so it's no effort just add them to the type definition.

So now you can define:

let myExperiment = ConfigDefinition<ABValues>(defaultValue: .b)
        .when(.userIsInternal(), return: .a)
        .userDefaults("my_experiment")
        .experimentManager("my_experiment")
        .tweak(category: "Feature Flags", section: "Experiments", name: "My experiment")

Custom providers

The library comes with a few predefined providers, such as UserDefaults, Filters or Tweaks, but the idea is to define new ones on demand as your apps needs it. For example, Firebase Remote Configurations is quite popular, you can define a provider for that. Or when your corporation uses custom experimentation/configuration services, it's easy to integrate.

public extension ConfigDefinition {
    func experimentManager(_ key: String) -> ConfigDefinition {
    let provider = DynamicValueProvider { MyExperimentManager.shared.currentValues[key] }
        return ConfigDefinition(id: id, defaultValue: defaultValue, providers: providers + [AnyProvider(provider)])
    }
}

The power of generics

Some services have restrictions to what types they support. For example, Firebase Remote Config only stores Integers, Booleans and Strings.

public protocol ConfigurableInFirebase {}
extension Int: ConfigurableInFirebase {}
extension Bool: ConfigurableInFirebase {}
extension String: ConfigurableInFirebase {}

This type restriction allows you do define constrained providers, such as:

public extension ConfigDefinition where Value: ConfigurableInFirebase {
    func firebaseRemoteConfig(key: FirebaseConfigKey<Value>) -> ConfigDefinition {
        let firebaseProvider = FirebaseConfigProvider<Value>(key: key)
        return ConfigDefinition(id: id, defaultValue: defaultValue, providers: providers + [AnyProvider(firebaseProvider)])
    }
}

So with this, you cannot store an arbitrary Data value, as the compiler won't let you.

This type safety allows you to achieve composition of types, so that for example custom Codable types could also be stored in UserDefaults by transforming it to JSON Data.

Custom conditions

As with providers, filters also have the very same extensibility traits, it's really easy to define custom ones:

public extension ConfigCondition {
    static func userEmail(hasSuffix value: String) -> ConfigCondition {
        ConfigCondition {
			userManager.currentUser?.email.hasSuffix(value) ?? false
        }
    }
    static func userIsInternal() -> ConfigCondition {
        userEmail(hasSuffix: "mycompany.com")
    }
}

and use it as follows:

let myExperiment = ConfigDefinition<ABValues>(defaultValue: .b)
        .when(.userIsInternal(), return: .a)

Tweaks

For a deeper description of how Tweaks work, please check out the documentation of the related Tweaks dependency.

License

See the LICENSE file for license rights and limitations (MIT).

About

Lightweight, extensible solution for configuring values on iOS

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages