From db74dd56a0946fd8439e3146ee953f7e6a0b2359 Mon Sep 17 00:00:00 2001 From: Brian Hackett Date: Wed, 15 Nov 2023 09:24:46 +0000 Subject: [PATCH] feat: Storage check (#61) * Added isEmpty func to Storage * Updated storage fake, fixed some warnings * static storage check, added to demo app and readme --- .../ConfidenceDemoApp/ConfidenceDemoApp.swift | 10 ++- .../ConfidenceDemoApp/ContentView.swift | 2 +- README.md | 9 +++ .../Cache/DefaultStorage.swift | 66 ++++++++++++++----- .../ConfidenceProvider/Cache/Storage.swift | 2 + .../ConfidenceFeatureProvider.swift | 18 ++++- .../CacheDataInteractorTests.swift | 4 +- .../CacheDataTests.swift | 20 +++--- .../Helpers/CacheDataUtility.swift | 2 +- .../Helpers/StorageMock.swift | 28 +++++--- 10 files changed, 117 insertions(+), 44 deletions(-) diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift b/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift index 3b3eb586..3bb4737f 100644 --- a/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift +++ b/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift @@ -19,10 +19,18 @@ extension ConfidenceDemoApp { guard let secret = ProcessInfo.processInfo.environment["CLIENT_SECRET"] else { return } + + // If we have no cache, then do a fetch first. + var initializationStratgey: InitializationStrategy = .activateAndFetchAsync + if ConfidenceFeatureProvider.isStorageEmpty() { + initializationStratgey = .fetchAndActivate + } + let provider = ConfidenceFeatureProvider .Builder(credentials: .clientSecret(secret: secret)) - .with(initializationStrategy: .activateAndFetchAsync) + .with(initializationStrategy: initializationStratgey) .build() + // NOTE: Using a random UUID for each app start is not advised and can result in getting stale values. let ctx = MutableContext(targetingKey: UUID.init().uuidString, structure: MutableStructure()) OpenFeatureAPI.shared.setProvider(provider: provider, initialContext: ctx) } diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift b/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift index e4c263df..bced791a 100644 --- a/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift +++ b/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift @@ -18,7 +18,7 @@ struct ContentView: View { text.text = OpenFeatureAPI .shared .getClient() - .getStringValue(key: "hawkflag.color", defaultValue: "ERROR") + .getStringValue(key: "swift-demoapp.color", defaultValue: "ERROR") if text.text == "Green" { color.color = .green } else if text.text == "Yellow" { diff --git a/README.md b/README.md index 4eda7125..ffb198a6 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,15 @@ The evaluation context is the way for the client to specify contextual data that The `setProvider()` function is synchronous and returns immediately, however this does not mean that the provider is ready to be used. An asynchronous network request to the Confidence backend to fetch all the flags configured for your application must be completed by the provider first. The provider will then emit a _READY_ event indicating you can start resolving flags. +A ultity function is available on the provider to check if the current storage has any values stored - this can be used to determine the best initialization strategy. +```swift +// If we have no cache, then do a fetch first. +var initializationStratgey: InitializationStrategy = .activateAndFetchAsync +if ConfidenceFeatureProvider.isStorageEmpty() { + initializationStratgey = .fetchAndActivate +} +``` + To listen for the _READY_ event, you can add an event handler via the `OpenFeatureAPI` shared instance: ```swift func providerReady(notification: Notification) { diff --git a/Sources/ConfidenceProvider/Cache/DefaultStorage.swift b/Sources/ConfidenceProvider/Cache/DefaultStorage.swift index dd87d27a..d4942f73 100644 --- a/Sources/ConfidenceProvider/Cache/DefaultStorage.swift +++ b/Sources/ConfidenceProvider/Cache/DefaultStorage.swift @@ -28,25 +28,14 @@ public class DefaultStorage: Storage { } public func load(defaultValue: T) throws -> T where T: Decodable { - try storageQueue.sync { - let configUrl = try getConfigUrl() - guard FileManager.default.fileExists(atPath: configUrl.backport.path) else { - return defaultValue - } - - let data = try { - do { - return try Data(contentsOf: configUrl) - } catch { - throw ConfidenceError.cacheError(message: "Unable to load cache file: \(error)") - } - }() + guard let data = try read() else { + return defaultValue + } - do { - return try JSONDecoder().decode(T.self, from: data) - } catch { - throw ConfidenceError.corruptedCache(message: "Unable to decode: \(error)") - } + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw ConfidenceError.corruptedCache(message: "Unable to decode: \(error)") } } @@ -65,6 +54,33 @@ public class DefaultStorage: Storage { } } + public func isEmpty() -> Bool { + guard let data = try? read() else { + return true + } + + return data.isEmpty + } + + func read() throws -> Data? { + try storageQueue.sync { + let configUrl = try getConfigUrl() + guard FileManager.default.fileExists(atPath: configUrl.backport.path) else { + return nil + } + + let data = try { + do { + return try Data(contentsOf: configUrl) + } catch { + throw ConfidenceError.cacheError(message: "Unable to load cache file: \(error)") + } + }() + + return data + } + } + func getConfigUrl() throws -> URL { guard let applicationSupportUrl = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) @@ -81,3 +97,17 @@ public class DefaultStorage: Storage { components: resolverCacheBundleId, "\(bundleIdentifier)", filePath) } } + +extension DefaultStorage { + public static func resolverFlagsCache() -> DefaultStorage { + DefaultStorage(filePath: "resolver.flags.cache") + } + + public static func resolverApplyCache() -> DefaultStorage { + DefaultStorage(filePath: "resolver.apply.cache") + } + + public static func applierFlagsCache() -> DefaultStorage { + DefaultStorage(filePath: "applier.flags.cache") + } +} diff --git a/Sources/ConfidenceProvider/Cache/Storage.swift b/Sources/ConfidenceProvider/Cache/Storage.swift index 6b1b99f4..d1133a22 100644 --- a/Sources/ConfidenceProvider/Cache/Storage.swift +++ b/Sources/ConfidenceProvider/Cache/Storage.swift @@ -6,4 +6,6 @@ public protocol Storage { func load(defaultValue: T) throws -> T where T: Decodable func clear() throws + + func isEmpty() -> Bool } diff --git a/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift b/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift index 74bb31e1..43c3f313 100644 --- a/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift +++ b/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift @@ -378,16 +378,28 @@ public class ConfidenceFeatureProvider: FeatureProvider { } } +// MARK: Storage + +extension ConfidenceFeatureProvider { + public static func isStorageEmpty( + storage: Storage = DefaultStorage.resolverFlagsCache() + ) -> Bool { + storage.isEmpty() + } +} + +// MARK: Builder + extension ConfidenceFeatureProvider { public struct Builder { var options: ConfidenceClientOptions var session: URLSession? var localOverrides: [String: LocalOverride] = [:] - var storage: Storage = DefaultStorage(filePath: "resolver.flags.cache") + var storage: Storage = DefaultStorage.resolverFlagsCache() var cache: ProviderCache? var flagApplier: (any FlagApplier)? var initializationStrategy: InitializationStrategy = .fetchAndActivate - var applyStorage: Storage = DefaultStorage(filePath: "resolver.apply.cache") + var applyStorage: Storage = DefaultStorage.resolverApplyCache() /// Initializes the builder with the given credentails. /// @@ -561,7 +573,7 @@ extension ConfidenceFeatureProvider { flagApplier ?? FlagApplierWithRetries( httpClient: NetworkClient(region: options.region), - storage: DefaultStorage(filePath: "applier.flags.cache"), + storage: DefaultStorage.applierFlagsCache(), options: options ) diff --git a/Tests/ConfidenceProviderTests/CacheDataInteractorTests.swift b/Tests/ConfidenceProviderTests/CacheDataInteractorTests.swift index ab6a1249..30ba8ace 100644 --- a/Tests/ConfidenceProviderTests/CacheDataInteractorTests.swift +++ b/Tests/ConfidenceProviderTests/CacheDataInteractorTests.swift @@ -34,7 +34,7 @@ final class CacheDataInteractorTests: XCTestCase { Task { // When cache data add method is called - await cacheDataInteractor.add(resolveToken: "token", flagName: "name", applyTime: Date()) + _ = await cacheDataInteractor.add(resolveToken: "token", flagName: "name", applyTime: Date()) // Then event is added with let cache = await cacheDataInteractor.cache @@ -49,7 +49,7 @@ final class CacheDataInteractorTests: XCTestCase { Task { // When cache data add method is called - await cacheDataInteractor.add(resolveToken: "token", flagName: "name", applyTime: Date()) + _ = await cacheDataInteractor.add(resolveToken: "token", flagName: "name", applyTime: Date()) // Then event is added with let cache = await cacheDataInteractor.cache diff --git a/Tests/ConfidenceProviderTests/CacheDataTests.swift b/Tests/ConfidenceProviderTests/CacheDataTests.swift index 34fb789a..38ef7789 100644 --- a/Tests/ConfidenceProviderTests/CacheDataTests.swift +++ b/Tests/ConfidenceProviderTests/CacheDataTests.swift @@ -11,7 +11,7 @@ final class CacheDataTests: XCTestCase { // When add event is called let applyTime = Date() - cacheData.add(resolveToken: "token1", flagName: "flagName", applyTime: applyTime) + _ = cacheData.add(resolveToken: "token1", flagName: "flagName", applyTime: applyTime) // Then cache data has one resolve event XCTAssertEqual(cacheData.resolveEvents.count, 1) @@ -29,7 +29,7 @@ final class CacheDataTests: XCTestCase { var cacheData = CacheData(resolveToken: "token1", events: []) // When add event is called with token that already exist in cache data - cacheData.add(resolveToken: "token1", flagName: "flagName", applyTime: applyTime) + _ = cacheData.add(resolveToken: "token1", flagName: "flagName", applyTime: applyTime) // Then cache data has one resolve event XCTAssertEqual(cacheData.resolveEvents.count, 1) @@ -46,9 +46,9 @@ final class CacheDataTests: XCTestCase { var cacheData = try CacheDataUtility.prefilledCacheData() // When add event is called 3 times with token that already exist in cache data - cacheData.add(resolveToken: "token0", flagName: "flagName", applyTime: Date()) - cacheData.add(resolveToken: "token0", flagName: "flagName2", applyTime: Date()) - cacheData.add(resolveToken: "token0", flagName: "flagName3", applyTime: Date()) + _ = cacheData.add(resolveToken: "token0", flagName: "flagName", applyTime: Date()) + _ = cacheData.add(resolveToken: "token0", flagName: "flagName2", applyTime: Date()) + _ = cacheData.add(resolveToken: "token0", flagName: "flagName3", applyTime: Date()) // Then cache data has 6 apply events XCTAssertEqual(cacheData.resolveEvents.first?.events.count, 6) @@ -58,11 +58,11 @@ final class CacheDataTests: XCTestCase { // Given pre filled cache data let applyTime = Date(timeIntervalSince1970: 1000) var cacheData = CacheData(resolveToken: "token1", events: []) - cacheData.add(resolveToken: "token1", flagName: "flagName", applyTime: applyTime) + _ = cacheData.add(resolveToken: "token1", flagName: "flagName", applyTime: applyTime) // When add event is called with token and flagName that already exist in cache let applyTimeOther = Date(timeIntervalSince1970: 3000) - cacheData.add(resolveToken: "token1", flagName: "flagName", applyTime: applyTimeOther) + _ = cacheData.add(resolveToken: "token1", flagName: "flagName", applyTime: applyTimeOther) // Then apply record is not overriden let applyEvent = try XCTUnwrap(cacheData.resolveEvents.first?.events.first) @@ -75,9 +75,9 @@ final class CacheDataTests: XCTestCase { let date = Date(timeIntervalSince1970: 2000) // When add event is called 3 times with different tokens - cacheData.add(resolveToken: "token1", flagName: "prefilled", applyTime: date) - cacheData.add(resolveToken: "token2", flagName: "prefilled", applyTime: date) - cacheData.add(resolveToken: "token3", flagName: "prefilled", applyTime: date) + _ = cacheData.add(resolveToken: "token1", flagName: "prefilled", applyTime: date) + _ = cacheData.add(resolveToken: "token2", flagName: "prefilled", applyTime: date) + _ = cacheData.add(resolveToken: "token3", flagName: "prefilled", applyTime: date) // Then cache data has 4 resolve event XCTAssertEqual(cacheData.resolveEvents.count, 4) diff --git a/Tests/ConfidenceProviderTests/Helpers/CacheDataUtility.swift b/Tests/ConfidenceProviderTests/Helpers/CacheDataUtility.swift index 99aea167..025dee3a 100644 --- a/Tests/ConfidenceProviderTests/Helpers/CacheDataUtility.swift +++ b/Tests/ConfidenceProviderTests/Helpers/CacheDataUtility.swift @@ -26,7 +26,7 @@ enum CacheDataUtility { let applyEvent = FlagApply(name: flagName, applyTime: date) applyEvents.append(applyEvent) } else { - cacheData.add(resolveToken: resolveToken, flagName: flagName, applyTime: date) + _ = cacheData.add(resolveToken: resolveToken, flagName: flagName, applyTime: date) } } diff --git a/Tests/ConfidenceProviderTests/Helpers/StorageMock.swift b/Tests/ConfidenceProviderTests/Helpers/StorageMock.swift index a883248f..bc98ec1f 100644 --- a/Tests/ConfidenceProviderTests/Helpers/StorageMock.swift +++ b/Tests/ConfidenceProviderTests/Helpers/StorageMock.swift @@ -6,8 +6,8 @@ import XCTest class StorageMock: Storage { var data = "" - var saveExpectation: XCTestExpectation? + private let storageQueue = DispatchQueue(label: "com.confidence.storagemock") convenience init(data: Encodable) throws { self.init() @@ -15,20 +15,32 @@ class StorageMock: Storage { } func save(data: Encodable) throws { - let dataB = try JSONEncoder().encode(data) - self.data = String(data: dataB, encoding: .utf8) ?? "" + try storageQueue.sync { + let dataB = try JSONEncoder().encode(data) + self.data = String(data: dataB, encoding: .utf8) ?? "" - saveExpectation?.fulfill() + saveExpectation?.fulfill() + } } func load(defaultValue: T) throws -> T where T: Decodable { - if data.isEmpty { - return defaultValue + try storageQueue.sync { + if data.isEmpty { + return defaultValue + } + return try JSONDecoder().decode(T.self, from: data.data) } - return try JSONDecoder().decode(T.self, from: data.data) } func clear() throws { - data = "" + storageQueue.sync { + data = "" + } + } + + func isEmpty() -> Bool { + storageQueue.sync { + return data.isEmpty + } } }