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

Add support for local overrides for feature flags #1074

Merged
merged 18 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from 14 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//
// FeatureFlagLocalOverrides.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation
import Persistence

/// This protocol defines persistence layer for feature flag overrides.
public protocol FeatureFlagLocalOverridesPersistor {
/// Return value for the flag override.
///
/// If there's no override, this function should return `nil`.
///
func value<Flag: FeatureFlagDescribing>(for flag: Flag) -> Bool?

/// Set new override for the feature flag.
///
/// Flag can be overridden to `true` or `false`. Setting `nil` clears the override.
///
func set<Flag: FeatureFlagDescribing>(_ value: Bool?, for flag: Flag)
}

public struct FeatureFlagLocalOverridesUserDefaultsPersistor: FeatureFlagLocalOverridesPersistor {

public let keyValueStore: KeyValueStoring

public init(keyValueStore: KeyValueStoring) {
self.keyValueStore = keyValueStore
}

public func value<Flag: FeatureFlagDescribing>(for flag: Flag) -> Bool? {
let key = key(for: flag)
return keyValueStore.object(forKey: key) as? Bool
}

public func set<Flag: FeatureFlagDescribing>(_ value: Bool?, for flag: Flag) {
let key = key(for: flag)
keyValueStore.set(value, forKey: key)
}

/// This function returns the User Defaults key for a feature flag override.
///
/// It uses camel case to allow inter-process User Defaults KVO.
///
private func key<Flag: FeatureFlagDescribing>(for flag: Flag) -> String {
return "localOverride\(flag.rawValue.capitalizedFirstLetter)"
}
}

private extension String {
var capitalizedFirstLetter: String {
return prefix(1).capitalized + dropFirst()
}
}

/// This protocol defines the callback that can be used to reacting to feature flag changes.
public protocol FeatureFlagLocalOverridesHandler {

/// This function is called whenever an effective value of a feature flag
/// changes as a result of adding or removing a local override.
///
/// It can be implemented by client apps to react to changes to feature flag
/// value in runtime, caused by adjusting its local override.
func flagDidChange<Flag: FeatureFlagDescribing>(_ featureFlag: Flag, isEnabled: Bool)
}

/// This protocol defines the interface for feature flag overriding mechanism.
///
/// All flag overrides APIs only have effect if flag has `supportsLocalOverriding` set to `true`.
///
public protocol FeatureFlagLocalOverriding: AnyObject {

/// Handle to the feature flagged.
///
/// It's used to query current, non-overriden state of a feature flag to
/// decide about calling `FeatureFlagLocalOverridesHandler.flagDidChange`
/// upon clearing an override.
var featureFlagger: FeatureFlagger? { get set }

/// Returns the current override for a feature flag, or `nil` if override is not set.
func override<Flag: FeatureFlagDescribing>(for featureFlag: Flag) -> Bool?

/// Toggles override for a feature flag.
///
/// If override is not currently present, it sets the override to the opposite of the current flag value.
///
func toggleOverride<Flag: FeatureFlagDescribing>(for featureFlag: Flag)

/// Clears override for a feature flag.
///
/// Calls `FeatureFlagLocalOverridesHandler.flagDidChange` if the effective flag value
/// changes as a result of clearing the override.
///
func clearOverride<Flag: FeatureFlagDescribing>(for featureFlag: Flag)

/// Clears overrides for all feature flags.
///
/// This function calls `clearOverride(for:)` for each flag.
///
func clearAllOverrides<Flag: FeatureFlagDescribing>(for flagType: Flag.Type)
}

public final class FeatureFlagLocalOverrides: FeatureFlagLocalOverriding {

private var persistor: FeatureFlagLocalOverridesPersistor
private var actionHandler: FeatureFlagLocalOverridesHandler
public weak var featureFlagger: FeatureFlagger?

public convenience init(
keyValueStore: KeyValueStoring,
actionHandler: FeatureFlagLocalOverridesHandler
) {
self.init(
persistor: FeatureFlagLocalOverridesUserDefaultsPersistor(keyValueStore: keyValueStore),
actionHandler: actionHandler
)
}

public init(
persistor: FeatureFlagLocalOverridesPersistor,
actionHandler: FeatureFlagLocalOverridesHandler
) {
self.persistor = persistor
self.actionHandler = actionHandler
}

public func override<Flag: FeatureFlagDescribing>(for featureFlag: Flag) -> Bool? {
guard featureFlag.supportsLocalOverriding else {
return nil
}
return persistor.value(for: featureFlag)
}

public func toggleOverride<Flag: FeatureFlagDescribing>(for featureFlag: Flag) {
guard featureFlag.supportsLocalOverriding else {
return
}
let currentValue = persistor.value(for: featureFlag) ?? currentValue(for: featureFlag) ?? false
let newValue = !currentValue
persistor.set(newValue, for: featureFlag)
actionHandler.flagDidChange(featureFlag, isEnabled: newValue)
}

public func clearOverride<Flag: FeatureFlagDescribing>(for featureFlag: Flag) {
guard let override = override(for: featureFlag) else {
return
}
persistor.set(nil, for: featureFlag)
if let defaultValue = currentValue(for: featureFlag), defaultValue != override {
actionHandler.flagDidChange(featureFlag, isEnabled: defaultValue)
}
}

public func clearAllOverrides<Flag: FeatureFlagDescribing>(for flagType: Flag.Type) {
flagType.allCases.forEach { flag in
clearOverride(for: flag)
}
}

private func currentValue<Flag: FeatureFlagDescribing>(for featureFlag: Flag) -> Bool? {
featureFlagger?.isFeatureOn(for: featureFlag, allowOverride: true)
}
}
193 changes: 132 additions & 61 deletions Sources/BrowserServicesKit/FeatureFlagger/FeatureFlagger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,147 @@

import Foundation

public protocol FeatureFlagger {

/// Called from app features to determine whether a given feature is enabled.
/// This protocol defines a common interface for feature flags managed by FeatureFlagger.
///
/// It should be implemented by the feature flag type in client apps.
///
/// `forProvider: F` takes a FeatureFlag type defined by the respective app which defines from what source it should be toggled
/// see `FeatureFlagSourceProviding` comments below for more details
func isFeatureOn<F: FeatureFlagSourceProviding>(forProvider: F) -> Bool
public protocol FeatureFlagDescribing: CaseIterable {

/// Returns a string representation of the flag, suitable for persisting the flag state to disk.
var rawValue: String { get }

/// Return `true` here if a flag can be locally overridden.
///
/// Local overriding mechanism requires passing `FeatureFlagOverriding` instance to
/// the `FeatureFlagger`. Then it will handle all feature flags that return `true` for
/// this property.
///
/// > Note: Local feature flag overriding is gated by the internal user flag and has no effect
/// as long as internal user flag is off.
var supportsLocalOverriding: Bool { get }

/// Defines the source of the feature flag, which corresponds to
/// where the final flag value should come from.
///
/// Example client implementation:
///
/// ```
/// public enum FeatureFlag: FeatureFlagDescribing {
/// case sync
/// case autofill
/// case cookieConsent
/// case duckPlayer
///
/// var source: FeatureFlagSource {
/// case .sync:
/// return .disabled
/// case .cookieConsent:
/// return .internalOnly
/// case .credentialsAutofill:
/// return .remoteDevelopment(.subfeature(AutofillSubfeature.credentialsAutofill))
/// case .duckPlayer:
/// return .remoteReleasable(.feature(.duckPlayer))
/// }
/// }
/// ```
var source: FeatureFlagSource { get }
}

public enum FeatureFlagSource {
/// Completely disabled in all configurations
case disabled

/// Enabled for internal users only. Cannot be toggled remotely
case internalOnly

/// Toggled remotely using PrivacyConfiguration but only for internal users. Otherwise, disabled.
case remoteDevelopment(PrivacyConfigFeatureLevel)

/// Toggled remotely using PrivacyConfiguration for all users
case remoteReleasable(PrivacyConfigFeatureLevel)
}

public enum PrivacyConfigFeatureLevel {
/// Corresponds to a given top-level privacy config feature
case feature(PrivacyFeature)

/// Corresponds to a given subfeature of a privacy config feature
case subfeature(any PrivacySubfeature)
}

public protocol FeatureFlagger: AnyObject {
var internalUserDecider: InternalUserDecider { get }

/// Local feature flag overriding mechanism.
///
/// This property is optional and if kept as `nil`, local overrides
/// are not in use. Local overrides are only ever considered if a user
/// is internal user.
var localOverrides: FeatureFlagLocalOverriding? { get }

/// Called from app features to determine whether a given feature is enabled.
///
/// Feature Flag's `source` is checked to determine if the flag should be toggled.
/// If feature flagger provides overrides mechanism (`localOverrides` is not `nil`)
/// and the user is internal, local overrides is checked first and if present,
/// returned as flag value.
///
/// > Note: Setting `allowOverride` to `false` skips checking local overrides. This can be used
/// when the non-overridden feature flag value is required.
///
func isFeatureOn<Flag: FeatureFlagDescribing>(for featureFlag: Flag, allowOverride: Bool) -> Bool
}

public extension FeatureFlagger {
/// Called from app features to determine whether a given feature is enabled.
///
/// Feature Flag's `source` is checked to determine if the flag should be toggled.
/// If feature flagger provides overrides mechanism (`localOverrides` is not `nil`)
/// and the user is internal, local overrides is checked first and if present,
/// returned as flag value.
///
func isFeatureOn<Flag: FeatureFlagDescribing>(for featureFlag: Flag) -> Bool {
isFeatureOn(for: featureFlag, allowOverride: true)
}
}

public class DefaultFeatureFlagger: FeatureFlagger {
private let internalUserDecider: InternalUserDecider
private let privacyConfigManager: PrivacyConfigurationManaging

public init(internalUserDecider: InternalUserDecider, privacyConfigManager: PrivacyConfigurationManaging) {
public let internalUserDecider: InternalUserDecider
public let privacyConfigManager: PrivacyConfigurationManaging
public let localOverrides: FeatureFlagLocalOverriding?

public init(
internalUserDecider: InternalUserDecider,
privacyConfigManager: PrivacyConfigurationManaging
) {
self.internalUserDecider = internalUserDecider
self.privacyConfigManager = privacyConfigManager
self.localOverrides = nil
}

public func isFeatureOn<F: FeatureFlagSourceProviding>(forProvider provider: F) -> Bool {
switch provider.source {
public init<Flag: FeatureFlagDescribing>(
internalUserDecider: InternalUserDecider,
privacyConfigManager: PrivacyConfigurationManaging,
localOverrides: FeatureFlagLocalOverriding,
for: Flag.Type
) {
self.internalUserDecider = internalUserDecider
self.privacyConfigManager = privacyConfigManager
self.localOverrides = localOverrides
localOverrides.featureFlagger = self

// Clear all overrides if not an internal user
if !internalUserDecider.isInternalUser {
localOverrides.clearAllOverrides(for: Flag.self)
}
}

public func isFeatureOn<Flag: FeatureFlagDescribing>(for featureFlag: Flag, allowOverride: Bool) -> Bool {
if allowOverride, internalUserDecider.isInternalUser, let localOverride = localOverrides?.override(for: featureFlag) {
return localOverride
}
switch featureFlag.source {
case .disabled:
return false
case .internalOnly:
Expand All @@ -61,53 +182,3 @@ public class DefaultFeatureFlagger: FeatureFlagger {
}
}
}

/// To be implemented by the FeatureFlag enum type in the respective app. The source corresponds to
/// where the final value should come from.
///
/// Example:
///
/// ```
/// public enum FeatureFlag: FeatureFlagSourceProviding {
/// case sync
/// case autofill
/// case cookieConsent
/// case duckPlayer
///
/// var source: FeatureFlagSource {
/// case .sync:
/// return .disabled
/// case .cookieConsent:
/// return .internalOnly
/// case .credentialsAutofill:
/// return .remoteDevelopment(.subfeature(AutofillSubfeature.credentialsAutofill))
/// case .duckPlayer:
/// return .remoteReleasable(.feature(.duckPlayer))
/// }
/// }
/// ```
public protocol FeatureFlagSourceProviding {
var source: FeatureFlagSource { get }
}

public enum FeatureFlagSource {
/// Completely disabled in all configurations
case disabled

/// Enabled for internal users only. Cannot be toggled remotely
case internalOnly

/// Toggled remotely using PrivacyConfiguration but only for internal users. Otherwise, disabled.
case remoteDevelopment(PrivacyConfigFeatureLevel)

/// Toggled remotely using PrivacyConfiguration for all users
case remoteReleasable(PrivacyConfigFeatureLevel)
}

public enum PrivacyConfigFeatureLevel {
/// Corresponds to a given top-level privacy config feature
case feature(PrivacyFeature)

/// Corresponds to a given subfeature of a privacy config feature
case subfeature(any PrivacySubfeature)
}
Loading
Loading