Skip to content

Commit

Permalink
Merge pull request #30 from NicFontana/feature/local-provider-value-u…
Browse files Browse the repository at this point in the history
…pdates-publisher

Feat: add LocalProvider value updates publisher
  • Loading branch information
malcommac committed Aug 31, 2023
2 parents d3bd373 + 8f1e7be commit 291b183
Show file tree
Hide file tree
Showing 6 changed files with 134 additions and 47 deletions.
2 changes: 1 addition & 1 deletion RealFlags/Sources/RealFlags/Classes/Flag/Flag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// Licensed under MIT License.
//

import UIKit
import Foundation

/// This a wrapper which represent a single Feature Flag.
/// The type that you wrap with `@Flag` must conform to `FlagProtocol`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public enum EncodedFlagValue: Equatable {
}
}

/// Trnsform boxed data in a valid `NSObject` you can store.
/// Transform boxed data in a valid `NSObject` you can store.
///
/// - Returns: NSObject
internal func nsObject() -> NSObject {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// Licensed under MIT License.
//

#if !os(Linux)
#if canImport(Combine)
import Combine
#endif

Expand Down Expand Up @@ -47,28 +47,26 @@ public protocol FlagsProvider {
/// Reset the value for keypath; it will remove the value from the record of the flag provider.
func resetValueForFlag(key: FlagKeyPath) throws

#if !os(Linux)
#if canImport(Combine)
// Apple platform also support Combine framework to provide realtime notification of new events to any subscriber.
// By default it does nothing (take a look to the default implementation in extension below).

/// You can use this value to receive updates when keys did updated in sources.
///
/// - Parameter keys: updated keys; may be `nil` if your provider does not support this kind of granularity.
func didUpdateValuesForKeys(_ keys: Set<String>?) -> AnyPublisher<Set<String>, Never>?
/// You can use this value to receive updates when a flag's values changes inside the current `FlagsProvider`.
func didUpdateValueForKey() -> AnyPublisher<(FlagKeyPath, (any FlagProtocol)?), Never>?

#endif

}

// MARK: - FlagsProvider (Default Publisher Behaviour)

#if !os(Linux)
#if canImport(Combine)

/// Make support for real-time flag updates optional by providing a default nil implementation
///
public extension FlagsProvider {

func didUpdateValuesForKeys(_ keys: Set<String>?) -> AnyPublisher<Set<String>, Never>? {
func didUpdateValueForKey() -> AnyPublisher<(FlagKeyPath, (any FlagProtocol)?), Never>? {
nil
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
//

import Foundation
#if canImport(Combine)
import Combine
#endif

/// LocalProvider is a local source for feature flags. You can use this object for persistent local data
/// or ephemeral storage.
Expand Down Expand Up @@ -39,6 +42,11 @@ public class LocalProvider: FlagsProvider, Identifiable {
/// Storage data.
internal var storage: [String: Any]

#if canImport(Combine)
/// `PassthroughSubject` that emits when a flag's values changes inside this `LocalProvider`
private let didUpdateValueForKeyPublisher: PassthroughSubject<(FlagKeyPath, (any FlagProtocol)?), Never> = .init()
#endif

// MARK: - Initialization

/// Initialize a new ephemeral storage which will be never saved locally.
Expand Down Expand Up @@ -82,6 +90,11 @@ public class LocalProvider: FlagsProvider, Identifiable {
BentoDict.setValueForDictionary(&storage, value: encodedValue, keyPath: key)

try saveToDisk()

#if canImport(Combine)
didUpdateValueForKeyPublisher.send((key, value))
#endif

return true
}

Expand Down Expand Up @@ -119,4 +132,10 @@ public class LocalProvider: FlagsProvider, Identifiable {
storage.removeAll()
}

#if canImport(Combine)
public func didUpdateValueForKey() -> AnyPublisher<(FlagKeyPath, (any FlagProtocol)?), Never>? {
didUpdateValueForKeyPublisher
.eraseToAnyPublisher()
}
#endif
}
43 changes: 43 additions & 0 deletions Tests/RealFlagsTests/LocalProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@

import Foundation
import XCTest
import Combine
@testable import RealFlags

class LocalProviderTests: XCTestCase {

fileprivate var loader: FlagsLoader<LPFlagsCollection>!
private var cancellables: Set<AnyCancellable>!

fileprivate lazy var localProviderFileURL: URL = {
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
Expand All @@ -29,6 +31,7 @@ class LocalProviderTests: XCTestCase {
override func setUp() {
super.setUp()
loader = FlagsLoader<LPFlagsCollection>(LPFlagsCollection.self, providers: [localProvider])
cancellables = []
}

override func tearDown() {
Expand Down Expand Up @@ -77,6 +80,46 @@ class LocalProviderTests: XCTestCase {
loader.nested.$flagInt.setDefault(2)
}
}

func testDidUpdateValueForKeyPublisher() throws {
// Setup subscriber
let expectation = expectation(description: "didUpdateValueForKeyPublisher")
var didUpdateValueForKeyReceivedValues: [(FlagKeyPath, (any FlagProtocol)?)] = []

localProvider.didUpdateValueForKey()?
.collect(5)
.sink(receiveValue: { values in
didUpdateValueForKeyReceivedValues = values
expectation.fulfill()
})
.store(in: &cancellables)

// Change values
let _ = try localProvider.setValue(true, forFlag: loader.$flagBool.keyPath)
let _ = try localProvider.setValue(false, forFlag: loader.$flagBool.keyPath)
let _ = try localProvider.setValue(10, forFlag: loader.nested.$flagInt.keyPath)
let _ = try localProvider.setValue("Test", forFlag: loader.nested.$flagString.keyPath)
let _ = try localProvider.setValue(Optional<String>.none, forFlag: loader.nested.$flagString.keyPath)

wait(for: [expectation], timeout: 10)

// Assertions on received values
XCTAssertEqual(5, didUpdateValueForKeyReceivedValues.count)
let keyPaths: [FlagKeyPath] = didUpdateValueForKeyReceivedValues.map { $0.0 }
let flags: [(any FlagProtocol)?] = didUpdateValueForKeyReceivedValues.map { $0.1 }

XCTAssertEqual(loader.$flagBool.keyPath, keyPaths[0])
XCTAssertEqual(loader.$flagBool.keyPath, keyPaths[1])
XCTAssertEqual(loader.nested.$flagInt.keyPath, keyPaths[2])
XCTAssertEqual(loader.nested.$flagString.keyPath, keyPaths[3])
XCTAssertEqual(loader.nested.$flagString.keyPath, keyPaths[4])

XCTAssertEqual(true.encoded(), flags[0]?.encoded())
XCTAssertEqual(false.encoded(), flags[1]?.encoded())
XCTAssertEqual(10.encoded(), flags[2]?.encoded())
XCTAssertEqual("Test".encoded(), flags[3]?.encoded())
XCTAssertEqual(nil, flags[4]?.encoded())
}
}


Expand Down
101 changes: 64 additions & 37 deletions documentation/advanced_usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@
- 3.1 - [Using FlagsManager](#31-using-flagsmanager)
- 3.2 - [Use and Creation of Data Providers](#32---use-and-creation-of-data-providers)
- 3.3 - [Firebase Remote Config with FirebaseRemoteProvider](#33-firebase-remote-config-with-firebaseremoteprovider)
- 3.4 - [Modify a feature flag at runtime](#34-modify-a-feature-flag-at-runtime)
- 3.5 - [Flags Browser & Editor](#35-flags-browser--editor)
- 3.4 - [Modify a feature flag value at runtime](#34-modify-a-feature-flag-value-at-runtime)
- 3.5 - [Listen for flag value updates over time](#35-listen-for-flag-value-updates-over-time)
- 3.6 - [Flags Browser & Editor](#36-flags-browser--editor)

## 3.1 Using `FlagsManager`

As you see above you need to allocate a `FlagsLoader` to read values for a feature flag collection. You also need to keep this flags loader instances around; `FlagsManager` allows you to collect, keep alive and manage feature flags collection easily.
As you can see above, you must allocate a `FlagsLoader` to read values of a feature flag collection. You also need to keep this flags loader instances around; `FlagsManager` allows you to collect, keep alive and manage feature flags collection easily.

While you don't need to use it we suggests it as entry point to query your data.
While you don't need to use it, we suggest it as entry point to query your data.

`FlagsManager` is pretty easy to use; consider you want to load structure for several feature flags collection.
Create a single instance of a `FlagsLoader` and use the `addCollection()` function; this is a silly example in `AppDelegate` but you can do better for sure:
`FlagsManager` is pretty easy to use; consider you want to load a structure for managing several feature flags collection.
Create a single instance of a `FlagsLoader` and use the `addCollection()` function; this is a silly example in `AppDelegate`, but you can do better for sure:

```swift
@main
Expand All @@ -37,7 +38,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
)

// Load some structures
manager.addCollection(removeCollection.self) // main collection
manager.addCollection(MainCollection.self) // main collection
manager.addCollection(ExperimentalFlags.self) // other collection

return manager
Expand All @@ -50,23 +51,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
```

Now you can forget to keep your collection alive or load them anytime you need of them. Just ask for `appFlags`:
Now you can forget to keep your collection alive or load them anytime you need them. Just ask for `appFlags`:

```swift
let cRatingMode = (UIApplication.shared as? AppDelegate)?.appFlags.ratingMode
```

You can also remove collection from `FlagsManager` using the `removeCollection()` function.

> NOTE: As you may have noticed only a single collection type can be loaded by a `FlagsManager`: you can't have multiple instances of the same collection type.
> NOTE: As you may have noticed, only a single collection type can be loaded by a `FlagsManager`: you can't have multiple instances of the same collection type.
[↑ INDEX](#3-advanced-usage)

## 3.2 - Use and Creation of Data Providers
## 3.2 - Usage and Creation of Data Providers

RealFlags create an abstract layer of the vertical feature flags implementations. You can use or create a new data provider which uses your service without altering the structure of your feature flags.
RealFlags create an abstract layer of the vertical feature flags implementations. You can use or create a new data provider, which uses your service without altering the structure of your feature flags.

Each data provider must be conform to the `FlagsProvider` protocol which defines two important methods:
Each data provider must conforms to the `FlagsProvider` protocol, which defines two important methods:

```swift
// get value
Expand All @@ -76,25 +77,25 @@ func valueForFlag<Value>(key: FlagKeyPath) -> Value? where Value: FlagProtocol
func setValue<Value>(_ value: Value?, forFlag key: FlagKeyPath) throws -> Bool where Value: FlagProtocol
```

One for getting value and another for set.
If your data provider does not provide writable support you must set the `isWritable = false` to inhibit the operation.
One for getting a value and the other to set it.
If your data provider does not provide writable support, you must set the `isWritable = false` to inhibit the operation.

RealFlags supports the following built-in providers:
- `LocalProvider` is a local XML based provider which support read&write and storage of any property type including all objects conform to `Codable` protocol. You can instantiate an ephimeral data provider (in-memory) or a file-backed provider to persists data between app restarts.
- any `UserDefaults` instance is conform to `FlagsProvider` so you can use it as a data provider. It supports read&write with also `Codable` objects supports too.
- `DelegateProvider` is just a forwarder object you can use to attach your own implementation without creating a `FlagsProvider` conform object.
- `LocalProvider` is a local XML based provider, which support read&write and storage for any types, including all objects conforming to the `Codable` protocol. You can instantiate an ephimeral data provider (in-memory), or a file-backed provider to persists data between app restarts.
- Any `UserDefaults` instance conforms to `FlagsProvider` so you can use it as a data provider. It allows read&write and supports `Codable` objects too.
- `DelegateProvider` is just a forwarder object you can use to attach your own implementation without creating a `FlagsProvider` conforming object.

Then the following remote providers (you can fetch from a separate package):
- `FirebaseRemoteProvider` supports Firebase Remote Config feature flag service. It supports only read and only for primitive types (no JSON is supported but you can use your own Strings).
Then, the following remote providers (you can fetch from a separate package):
- `FirebaseRemoteProvider` supports Firebase Remote Config feature flag service. It is a read-only provider and supports only primitive types (no JSON is supported but you can use your own Strings).

[↑ INDEX](#3-advanced-usage)

## 3.3 Firebase Remote Config with `FirebaseRemoteProvider`

`FirebaseRemoteProvider` is the data provider for [Firebase Remote Config](https://firebase.google.com/docs/remote-config) service.
You can use it by fetching the additional `RealFlagsFirebase` from SwiftPM or CocoaPods (it will dependeds by [Firebase iOS SDK](https://github.com/firebase/firebase-ios-sdk.git)).
`FirebaseRemoteProvider` is the data provider for the [Firebase Remote Config](https://firebase.google.com/docs/remote-config) service.
You can use it by fetching the additional `RealFlagsFirebase` package from SwiftPM or CocoaPods (it will dependeds by [Firebase iOS SDK](https://github.com/firebase/firebase-ios-sdk.git)).

Once fetched remember to configure your Firebase SDK before load the provider.
Once fetched, remember to configure your Firebase SDK before load the provider.
This is just an example:

```swift
Expand All @@ -120,7 +121,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
```

You can also use the `delegate` to receive important events from SDK:
You can also use the `delegate` to receive important events from the SDK:

```swift
extension AppDelegate: FirebaseRemoteConfigProviderDelegate {
Expand All @@ -136,9 +137,9 @@ extension AppDelegate: FirebaseRemoteConfigProviderDelegate {

[↑ INDEX](#3-advanced-usage)

## 3.4 Modify a feature flag at runtime
## 3.4 Modify a feature flag value at runtime

While generally you don't need to modify at runtime the value of a feature flag, sometimes you may need of this feature.
While you generally don't need to modify a feature flag's value at runtime, sometimes you may need this feature.

Each `@Flag` annotated property supports the `setValue()` function to alter its value; this is allowed by accessing to the projected value:

Expand All @@ -147,8 +148,8 @@ appFlags.$ratingMode.setValue("altered value",
providers: [LocalProvider.self])
```

In addition to the new value it also takes the list of data providers to afftect.
Obivously not all data provider supports writing new values; for example you can't send values to the Firebase Remote Config via `FirebaseRemoteProvider`.
In addition to the new value it also takes the list of data providers to affect.
Obivously, not all data provider supports writing new values; for example you can't send values to the Firebase Remote Config via `FirebaseRemoteProvider`.
However you can store a new value in a `LocalProvider` instance.

The method return a boolean indicating the result of the operation.
Expand All @@ -161,18 +162,44 @@ let json = JSONData(["column": 5, "showDesc": true, "title": true])
appFlags.ui.$layoutAttributes.setValue(json, providers: [LocalProvider.self])
```

Another way to update a flag's value is by set it using the `FlagsProvider` instance itself:
```swift
let json = JSONData(["column": 5, "showDesc": true, "title": true])
appFlagsLocalProvider.setValue(json, forFlag: appFlags.ui.$layoutAttributes.keyPath)
```
[↑ INDEX](#3-advanced-usage)

## 3.5 Listen for flag value updates over time
Every `FlagsProvider` conforming type expose an optional Combine Publisher you can subscribe to to listen for flag value updates.

This Publisher is returned by the following method:
```swift
func didUpdateValueForKey() -> AnyPublisher<(FlagKeyPath, (any FlagProtocol)?), Never>?
```
Currently, only `LocalProvider` objects return a Publisher.
Usage example:
```swift
var cancellables: Set<AnyCancellable> = []

appFlagsLocalProvider.didUpdateValueForKey()?
.sink { (keyPath, value) in
print("New value for flag \(keyPath.fullPath): \(value?.encoded())")
}
.store(in: &cancellables)
```

[↑ INDEX](#3-advanced-usage)

## 3.5 Flags Browser & Editor
## 3.6 Flags Browser & Editor

Flags Browser is a small UI tool for displaying and manipulating feature flags.
It's part of the library and you can include it in your product like inside the developer's mode or similar.
It's part of the library and you can include it in your product, like inside the developer's mode or similar.

![](./assets/flags_browser_video.mp4)

It's really easy to use, just allocate and push our built-in view controller by passing a list of `FlagsLoader` or a `FlagsManager`; RealFlags takes care to read all values and show them in a confortable user interface.
It's really easy to use, just allocate and push our built-in view controller by passing a list of `FlagsLoader` or a `FlagsManager`; RealFlags takes care to read all the values and show them in a confortable user interface.

If you are using `FlagsManager` to keep organized your flags just pass it to the init:
If you are using `FlagsManager` to keep your flags organized, just pass it to the init:

```swift
var flagsManager = FlagsManager(providers: [...])
Expand All @@ -183,7 +210,7 @@ func showFlagsBrowser() {
}
```

Otherwise you can pass one or more `FlagsLoader` you wanna show:
Otherwise, you can pass one or more `FlagsLoader` that you want to show:

```swift
var userFlags: FlagsLoader<UserFlagsCollection> = ...
Expand All @@ -195,16 +222,16 @@ func showFlagsBrowser() {
}
```

And you're done! You will get a cool UI with detailed description of each flags along its types and values for each data provider which allows developers and product owners to check and alter the state of the app!
And you're done! You will get a cool UI with detailed description of each flags along its types and values for each data provider, which allows developers and product owners to check and alter the state of the app!

The following properties of `@Flag` properties are used by the Flags Browser to render the UI:

- `name`: provide the readable name of the property. If not specified the signature of the property is used automatically.
- `name`: provide the readable name of the property. If not specified, the signature of the property is used automatically.
- `description`: provide a short description of the property which is useful both for devs and product owners.
- `keyPath`: provide the full keypath of the property which is queried to any set data providers. If not specified it will be evaluated automatically ([see here](./documentation/introduction.md#1.1)).
- `isUILocked`: set to `true` in order to prevent altering the property from the Flags Browser. By default is `false`.
- `defaultValue`: show the value of fallback when no value can be obtained to any data provider.
- `metadata`: contains useful settings for flag (see below)
- `isUILocked`: set it to `true` in order to prevent altering the property from the Flags Browser. By default is `false`.
- `defaultValue`: shows the fallback value when no value can be obtained from any data provider.
- `metadata`: contains useful settings for flag (see below).

A `Flag` instance also include a `FlagMetadata` object (via `.metadata`) which exposes several additional properties:

Expand Down

0 comments on commit 291b183

Please sign in to comment.