From 3ce6b8d7f14150c77b363df0af3ce41c0e80138d Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Mon, 29 Jan 2024 17:31:40 +0100 Subject: [PATCH] feat: Add setProviderAndWait (#30) Adds `setProviderAndWait` extension function, exposed by this library as a user-facing API (documentation also updated). The application can now use `async/await` to wait for the Provider to be ready, before reading flags. The older alternative (still available) is for the application to call `setProvider` and listen for `.ready` event manually. --------- Signed-off-by: Fabrizio Demaria --- README.md | 28 +++---- Sources/OpenFeature/OpenFeatureAPI.swift | 27 +++++++ .../DeveloperExperienceTests.swift | 33 +++++++++ .../InjectableEventHandlerProvider.swift | 73 +++++++++++++++++++ 4 files changed, 145 insertions(+), 16 deletions(-) create mode 100644 Tests/OpenFeatureTests/Helpers/InjectableEventHandlerProvider.swift diff --git a/README.md b/README.md index 2e35b82..09d59a1 100644 --- a/README.md +++ b/README.md @@ -76,23 +76,17 @@ and in the target dependencies section add: ```swift import OpenFeature -// Configure your custom `FeatureProvider` and pass it to OpenFeatureAPI -let customProvider = MyCustomProvider() -OpenFeatureAPI.shared.setProvider(provider: customProvider) - -// Configure your evaluation context and pass it to OpenFeatureAPI -let ctx = MutableContext( - targetingKey: userId, - structure: MutableStructure(attributes: ["product": Value.string(productId)])) -OpenFeatureAPI.shared.setEvaluationContext(evaluationContext: ctx) - -// Get client from OpenFeatureAPI and evaluate your flags -let client = OpenFeatureAPI.shared.getClient() -let flagValue = client.getBooleanValue(key: "boolFlag", defaultValue: false) +Task { + let provider = CustomProvider() + // configure a provider, wait for it to complete its initialization tasks + await OpenFeatureAPI.shared.setProviderAndWait(provider: provider) + + // get a bool flag value + let client = OpenFeatureAPI.shared.getClient() + let flagValue = client.getBooleanValue(key: "boolFlag", defaultValue: false) +} ``` -Setting a new provider or setting a new evaluation context might trigger asynchronous operations (e.g. fetching flag evaluations from the backend and store them in a local cache). It's advised to not interact with the OpenFeature client until the `ProviderReady` event has been sent (see [Eventing](#eventing) below). - ## 🌟 Features @@ -118,9 +112,11 @@ If the provider you're looking for hasn't been created yet, see the [develop a p Once you've added a provider as a dependency, it can be registered with OpenFeature like this: ```swift -OpenFeatureAPI.shared.setEvaluationContext(evaluationContext: ctx) +await OpenFeatureAPI.shared.setProviderAndWait(provider: MyProvider()) ``` +> Asynchronous API that doesn't wait is also available + ### Targeting Sometimes, the value of a flag must consider some dynamic criteria about the application or user, such as the user's location, IP, email address, or the server's location. diff --git a/Sources/OpenFeature/OpenFeatureAPI.swift b/Sources/OpenFeature/OpenFeatureAPI.swift index 4da3415..5fefd91 100644 --- a/Sources/OpenFeature/OpenFeatureAPI.swift +++ b/Sources/OpenFeature/OpenFeatureAPI.swift @@ -85,3 +85,30 @@ public class OpenFeatureAPI { .eraseToAnyPublisher() } } + +extension OpenFeatureAPI { + public func setProviderAndWait(provider: FeatureProvider) async { + await setProviderAndWait(provider: provider, initialContext: nil) + } + + public func setProviderAndWait(provider: FeatureProvider, initialContext: EvaluationContext?) async { + let task = Task { + var holder: [AnyCancellable] = [] + await withCheckedContinuation { continuation in + let stateObserver = provider.observe().sink { + if $0 == .ready { + continuation.resume() + holder.removeAll() + } + } + stateObserver.store(in: &holder) + setProvider(provider: provider, initialContext: initialContext) + } + } + await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + } +} diff --git a/Tests/OpenFeatureTests/DeveloperExperienceTests.swift b/Tests/OpenFeatureTests/DeveloperExperienceTests.swift index 64e9ded..4976502 100644 --- a/Tests/OpenFeatureTests/DeveloperExperienceTests.swift +++ b/Tests/OpenFeatureTests/DeveloperExperienceTests.swift @@ -49,6 +49,39 @@ final class DeveloperExperienceTests: XCTestCase { XCTAssertNotNil(eventState) } + func testSetProviderAndWait() async { + let readyExpectation = XCTestExpectation(description: "Ready") + let errorExpectation = XCTestExpectation(description: "Error") + let staleExpectation = XCTestExpectation(description: "Stale") + withExtendedLifetime( + OpenFeatureAPI.shared.observe().sink { event in + switch event { + case ProviderEvent.ready: + readyExpectation.fulfill() + case ProviderEvent.error: + errorExpectation.fulfill() + case ProviderEvent.stale: + staleExpectation.fulfill() + default: + XCTFail("Unexpected event") + } + }) + { + let initCompleteExpectation = XCTestExpectation() + + let eventHandler = EventHandler(.stale) + let provider = InjectableEventHandlerProvider(eventHandler: eventHandler) + Task { + await OpenFeatureAPI.shared.setProviderAndWait(provider: provider) + wait(for: [readyExpectation], timeout: 0) + initCompleteExpectation.fulfill() + } + wait(for: [staleExpectation], timeout: 1) + eventHandler.send(.ready) + wait(for: [initCompleteExpectation], timeout: 2) + } + } + func testClientHooks() { OpenFeatureAPI.shared.setProvider(provider: NoOpProvider()) let client = OpenFeatureAPI.shared.getClient() diff --git a/Tests/OpenFeatureTests/Helpers/InjectableEventHandlerProvider.swift b/Tests/OpenFeatureTests/Helpers/InjectableEventHandlerProvider.swift new file mode 100644 index 0000000..dd435e4 --- /dev/null +++ b/Tests/OpenFeatureTests/Helpers/InjectableEventHandlerProvider.swift @@ -0,0 +1,73 @@ +import Foundation +import OpenFeature +import Combine + +class InjectableEventHandlerProvider: FeatureProvider { + public static let name = "InjectableEventHandler" + private let eventHandler: EventHandler + + init(eventHandler: EventHandler) { + self.eventHandler = eventHandler + } + + func onContextSet(oldContext: OpenFeature.EvaluationContext?, newContext: OpenFeature.EvaluationContext) { + // Emit stale, then let the parent test control events via eventHandler + eventHandler.send(.stale) + } + + func initialize(initialContext: OpenFeature.EvaluationContext?) { + // Emit stale, then let the parent test control events via eventHandler + eventHandler.send(.stale) + } + + var hooks: [any OpenFeature.Hook] = [] + var metadata: OpenFeature.ProviderMetadata = InjectableEventHandlerMetadata() + + func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws + -> ProviderEvaluation< + Bool + > + { + return ProviderEvaluation(value: !defaultValue) + } + + func getStringEvaluation(key: String, defaultValue: String, context: EvaluationContext?) throws + -> ProviderEvaluation< + String + > + { + return ProviderEvaluation(value: String(defaultValue.reversed())) + } + + func getIntegerEvaluation(key: String, defaultValue: Int64, context: EvaluationContext?) throws + -> ProviderEvaluation< + Int64 + > + { + return ProviderEvaluation(value: defaultValue * 100) + } + + func getDoubleEvaluation(key: String, defaultValue: Double, context: EvaluationContext?) throws + -> ProviderEvaluation< + Double + > + { + return ProviderEvaluation(value: defaultValue * 100) + } + + func getObjectEvaluation(key: String, defaultValue: Value, context: EvaluationContext?) throws + -> ProviderEvaluation< + Value + > + { + return ProviderEvaluation(value: .null) + } + + func observe() -> AnyPublisher { + eventHandler.observe() + } + + public struct InjectableEventHandlerMetadata: ProviderMetadata { + public var name: String? = InjectableEventHandlerProvider.name + } +}