From ac150987ad2ff7657e7d976cd304880bc6d33784 Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Wed, 4 Sep 2024 16:39:54 +0200 Subject: [PATCH] feat!: Align ProviderState and ProviderEvent with Spec Signed-off-by: Fabrizio Demaria --- Sources/OpenFeature/EventHandler.swift | 17 +- Sources/OpenFeature/OpenFeatureAPI.swift | 158 +++++++++++++----- Sources/OpenFeature/OpenFeatureClient.swift | 59 +++++-- .../Provider/FeatureProvider.swift | 8 +- .../OpenFeature/Provider/NoOpProvider.swift | 4 +- .../OpenFeature/Provider/ProviderEvents.swift | 17 +- .../OpenFeature/Provider/ProviderStatus.swift | 10 ++ .../exceptions/OpenFeatureError.swift | 6 +- .../DeveloperExperienceTests.swift | 67 +++++--- .../FlagEvaluationTests.swift | 31 +--- .../Helpers/AlwaysBrokenProvider.swift | 16 +- .../Helpers/DoSomethingProvider.swift | 7 +- .../InjectableEventHandlerProvider.swift | 71 -------- .../Helpers/StaggeredProvider.swift | 79 +++++++++ .../Helpers/ThrowingProvider.swift | 58 +++++++ Tests/OpenFeatureTests/HookSpecTests.swift | 13 +- .../ProviderEventsTests.swift | 23 --- 17 files changed, 391 insertions(+), 253 deletions(-) create mode 100644 Sources/OpenFeature/Provider/ProviderStatus.swift delete mode 100644 Tests/OpenFeatureTests/Helpers/InjectableEventHandlerProvider.swift create mode 100644 Tests/OpenFeatureTests/Helpers/StaggeredProvider.swift create mode 100644 Tests/OpenFeatureTests/Helpers/ThrowingProvider.swift delete mode 100644 Tests/OpenFeatureTests/ProviderEventsTests.swift diff --git a/Sources/OpenFeature/EventHandler.swift b/Sources/OpenFeature/EventHandler.swift index d4a92ee..ed80b37 100644 --- a/Sources/OpenFeature/EventHandler.swift +++ b/Sources/OpenFeature/EventHandler.swift @@ -2,29 +2,24 @@ import Combine import Foundation public class EventHandler: EventSender, EventPublisher { - private let eventState: CurrentValueSubject + private let lastSentEvent = PassthroughSubject() - convenience init() { - self.init(.notReady) + public init() { } - public init(_ state: ProviderEvent) { - eventState = CurrentValueSubject(state) - } - - public func observe() -> AnyPublisher { - return eventState.eraseToAnyPublisher() + public func observe() -> AnyPublisher { + return lastSentEvent.eraseToAnyPublisher() } public func send( _ event: ProviderEvent ) { - eventState.send(event) + lastSentEvent.send(event) } } public protocol EventPublisher { - func observe() -> AnyPublisher + func observe() -> AnyPublisher } public protocol EventSender { diff --git a/Sources/OpenFeature/OpenFeatureAPI.swift b/Sources/OpenFeature/OpenFeatureAPI.swift index c91fe2c..405f9c3 100644 --- a/Sources/OpenFeature/OpenFeatureAPI.swift +++ b/Sources/OpenFeature/OpenFeatureAPI.swift @@ -4,17 +4,13 @@ import Foundation /// A global singleton which holds base configuration for the OpenFeature library. /// Configuration here will be shared across all ``Client``s. public class OpenFeatureAPI { - private var _provider: FeatureProvider? { - get { - providerSubject.value - } - set { - providerSubject.send(newValue) - } - } - private var _context: EvaluationContext? + private let eventHandler = EventHandler() + private let queue = DispatchQueue(label: "com.providerDescriptor.queue") + + private(set) var providerSubject = CurrentValueSubject(nil) + private(set) var evaluationContext: EvaluationContext? + private(set) var providerStatus: ProviderStatus = .notReady private(set) var hooks: [any Hook] = [] - private var providerSubject = CurrentValueSubject(nil) /// The ``OpenFeatureAPI`` singleton static public let shared = OpenFeatureAPI() @@ -22,34 +18,76 @@ public class OpenFeatureAPI { public init() { } - public func setProvider(provider: FeatureProvider) { - self.setProvider(provider: provider, initialContext: nil) + /** + Set provider and calls its `initialize` in a background thread. + Readiness can be determined from `getState` or listening for `ready` event. + */ + public func setProvider(provider: FeatureProvider, initialContext: EvaluationContext?) { + queue.async { + Task { + await self.setProviderInternal(provider: provider, initialContext: initialContext) + } + } } - public func setProvider(provider: FeatureProvider, initialContext: EvaluationContext?) { - self._provider = provider - if let context = initialContext { - self._context = context + /** + Set provider and calls its `initialize`. + This async function returns when the initalize from the provider is completed. + */ + public func setProviderAndWait(provider: FeatureProvider, initialContext: EvaluationContext?) async { + await withCheckedContinuation { continuation in + queue.async { + Task { + await self.setProviderInternal(provider: provider, initialContext: initialContext) + continuation.resume() + } + } } - provider.initialize(initialContext: self._context) + } + + /** + Set provider and calls its `initialize` in a background thread. + Readiness can be determined from `getState` or listening for `ready` event. + */ + public func setProvider(provider: FeatureProvider) { + setProvider(provider: provider, initialContext: nil) + } + + /** + Set provider and calls its `initialize`. + This async function returns when the initalize from the provider is completed. + */ + public func setProviderAndWait(provider: FeatureProvider) async { + // TODO On reentrant calls, we should wait for the last one to terminate + await setProviderAndWait(provider: provider, initialContext: nil) } public func getProvider() -> FeatureProvider? { - return self._provider + return self.providerSubject.value } public func clearProvider() { - self._provider = nil + queue.sync { + self.providerSubject.send(nil) + self.providerStatus = .notReady + } } + // TODO Should we add setEvaluationContextAndWait? public func setEvaluationContext(evaluationContext: EvaluationContext) { - let oldContext = self._context - self._context = evaluationContext - getProvider()?.onContextSet(oldContext: oldContext, newContext: evaluationContext) + queue.async { + Task { + await self.updateContext(evaluationContext: evaluationContext) + } + } } public func getEvaluationContext() -> EvaluationContext? { - return self._context + return self.evaluationContext + } + + public func getProviderStatus() -> ProviderStatus { + return self.providerStatus } public func getProviderMetadata() -> ProviderMetadata? { @@ -72,43 +110,73 @@ public class OpenFeatureAPI { self.hooks.removeAll() } - public func observe() -> AnyPublisher { + // TODO OpenFeatureAPI should listen for Provider's events and change state accordingly + public func observe() -> AnyPublisher { return providerSubject.map { provider in if let provider = provider { return provider.observe() + .merge(with: self.eventHandler.observe()) + .eraseToAnyPublisher() } else { - return Empty() + return Empty() .eraseToAnyPublisher() } } .switchToLatest() .eraseToAnyPublisher() } -} -extension OpenFeatureAPI { - public func setProviderAndWait(provider: FeatureProvider) async { - await setProviderAndWait(provider: provider, initialContext: nil) + internal func getState() -> OpenFeatureState { + return queue.sync { + OpenFeatureState( + provider: providerSubject.value, + evaluationContext: evaluationContext, + providerStatus: providerStatus) + } } - 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 || $0 == .error { - continuation.resume() - holder.removeAll() - } - } - stateObserver.store(in: &holder) - setProvider(provider: provider, initialContext: initialContext) + private func setProviderInternal(provider: FeatureProvider, initialContext: EvaluationContext? = nil) async { + self.providerStatus = .notReady + self.providerSubject.send(provider) + + if let initialContext = initialContext { + self.evaluationContext = initialContext + } + + do { + try await provider.initialize(initialContext: initialContext) + self.providerStatus = .ready + self.eventHandler.send(.ready) + } catch { + switch error { + case OpenFeatureError.providerFatalError: + self.providerStatus = .fatal + self.eventHandler.send(.error(errorCode: .providerFatal)) + default: + self.providerStatus = .error + self.eventHandler.send(.error(message: error.localizedDescription)) } } - await withTaskCancellationHandler { - await task.value - } onCancel: { - task.cancel() + } + + private func updateContext(evaluationContext: EvaluationContext) async { + do { + let oldContext = self.evaluationContext + self.evaluationContext = evaluationContext + self.providerStatus = .reconciling + eventHandler.send(.reconciling) + try await self.providerSubject.value?.onContextSet(oldContext: oldContext, newContext: evaluationContext) + self.providerStatus = .ready + eventHandler.send(.contextChanged) + } catch { + self.providerStatus = .error + eventHandler.send(.error(message: error.localizedDescription)) } } + + struct OpenFeatureState { + let provider: FeatureProvider? + let evaluationContext: EvaluationContext? + let providerStatus: ProviderStatus + } } diff --git a/Sources/OpenFeature/OpenFeatureClient.swift b/Sources/OpenFeature/OpenFeatureClient.swift index cdd933f..e957171 100644 --- a/Sources/OpenFeature/OpenFeatureClient.swift +++ b/Sources/OpenFeature/OpenFeatureClient.swift @@ -68,12 +68,48 @@ extension OpenFeatureClient { defaultValue: T, options: FlagEvaluationOptions? ) -> FlagEvaluationDetails { - let options = options ?? FlagEvaluationOptions(hooks: [], hookHints: [:]) - let hints = options.hookHints - let context = openFeatureApi.getEvaluationContext() + let openFeatureApiState = openFeatureApi.getState() + var details = FlagEvaluationDetails(flagKey: key, value: defaultValue) + switch openFeatureApiState.providerStatus { + case .fatal: + details.errorCode = .providerFatal + details.errorMessage = OpenFeatureError + .providerFatalError(message: "unknown") + .description // TODO Improve this message with error details + details.reason = Reason.error.rawValue + return details + case .notReady: + details.errorCode = .providerNotReady + details.errorMessage = OpenFeatureError.providerNotReadyError.description + details.reason = Reason.error.rawValue + return details + case .reconciling, .stale: + details.reason = Reason.stale.rawValue + return details + case .error: + details.errorCode = .general + details.errorMessage = OpenFeatureError + .generalError(message: "unknown") + .description // TODO Improve this message with error details + details.reason = Reason.error.rawValue + return details + case .ready: + return evaluateFlagReady( + key: key, defaultValue: defaultValue, options: options, openFeatureApiState: openFeatureApiState) + } + } + private func evaluateFlagReady( + key: String, + defaultValue: T, + options: FlagEvaluationOptions?, + openFeatureApiState: OpenFeatureAPI.OpenFeatureState + ) -> FlagEvaluationDetails { var details = FlagEvaluationDetails(flagKey: key, value: defaultValue) - let provider = openFeatureApi.getProvider() ?? NoOpProvider() + let options = options ?? FlagEvaluationOptions(hooks: [], hookHints: [:]) + let hints = options.hookHints + let context = openFeatureApiState.evaluationContext + let provider = openFeatureApiState.provider ?? NoOpProvider() let hookCtx = HookContext( flagKey: key, type: T.flagValueType, @@ -81,45 +117,34 @@ extension OpenFeatureClient { ctx: context, clientMetadata: self.metadata, providerMetadata: provider.metadata) - hookLock.lock() let mergedHooks = provider.hooks + options.hooks + hooks + openFeatureApi.hooks hookLock.unlock() - do { hookSupport.beforeHooks(flagValueType: T.flagValueType, hookCtx: hookCtx, hooks: mergedHooks, hints: hints) - let providerEval = try createProviderEvaluation( key: key, context: context, defaultValue: defaultValue, provider: provider) - - let evalDetails = FlagEvaluationDetails.from(providerEval: providerEval, flagKey: key) - details = evalDetails - + details = FlagEvaluationDetails.from(providerEval: providerEval, flagKey: key) try hookSupport.afterHooks( - flagValueType: T.flagValueType, hookCtx: hookCtx, details: evalDetails, hooks: mergedHooks, hints: hints + flagValueType: T.flagValueType, hookCtx: hookCtx, details: details, hooks: mergedHooks, hints: hints ) } catch { logger.error("Unable to correctly evaluate flag with key \(key) due to exception \(error)") - if let error = error as? OpenFeatureError { details.errorCode = error.errorCode() } else { details.errorCode = .general } - details.errorMessage = "\(error)" details.reason = Reason.error.rawValue - hookSupport.errorHooks( flagValueType: T.flagValueType, hookCtx: hookCtx, error: error, hooks: mergedHooks, hints: hints) } - hookSupport.afterAllHooks( flagValueType: T.flagValueType, hookCtx: hookCtx, hooks: mergedHooks, hints: hints) - return details } diff --git a/Sources/OpenFeature/Provider/FeatureProvider.swift b/Sources/OpenFeature/Provider/FeatureProvider.swift index a5af609..34cdd8c 100644 --- a/Sources/OpenFeature/Provider/FeatureProvider.swift +++ b/Sources/OpenFeature/Provider/FeatureProvider.swift @@ -6,10 +6,14 @@ public protocol FeatureProvider: EventPublisher { var metadata: ProviderMetadata { get } /// Called by OpenFeatureAPI whenever the new Provider is registered - func initialize(initialContext: EvaluationContext?) + /// This must throw in case of error, using OpenFeature errors whenever possible + /// It is expected that the implementer is slow (e.g. network), hence the async nature of the protocol + func initialize(initialContext: EvaluationContext?) async throws /// Called by OpenFeatureAPI whenever a new EvaluationContext is set by the application - func onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) + /// This must throw in case of error, using OpenFeature errors whenever possible + /// It is expected that the implementer is slow (e.g. network), hence the async nature of the protocol + func onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) async throws func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws -> ProviderEvaluation< diff --git a/Sources/OpenFeature/Provider/NoOpProvider.swift b/Sources/OpenFeature/Provider/NoOpProvider.swift index d9674fb..9daa14c 100644 --- a/Sources/OpenFeature/Provider/NoOpProvider.swift +++ b/Sources/OpenFeature/Provider/NoOpProvider.swift @@ -15,11 +15,9 @@ class NoOpProvider: FeatureProvider { var hooks: [any Hook] = [] func onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { - eventHandler.send(.ready) } func initialize(initialContext: EvaluationContext?) { - eventHandler.send(.ready) } func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws @@ -67,7 +65,7 @@ class NoOpProvider: FeatureProvider { value: defaultValue, variant: NoOpProvider.passedInDefault, reason: Reason.defaultReason.rawValue) } - func observe() -> AnyPublisher { + func observe() -> AnyPublisher { return eventHandler.observe() } } diff --git a/Sources/OpenFeature/Provider/ProviderEvents.swift b/Sources/OpenFeature/Provider/ProviderEvents.swift index 2f40a44..25a8406 100644 --- a/Sources/OpenFeature/Provider/ProviderEvents.swift +++ b/Sources/OpenFeature/Provider/ProviderEvents.swift @@ -1,13 +1,10 @@ import Foundation -public let providerEventDetailsKeyProvider = "Provider" -public let providerEventDetailsKeyClient = "Client" -public let providerEventDetailsKeyError = "Error" - -public enum ProviderEvent: String, CaseIterable { - case ready = "PROVIDER_READY" - case error = "PROVIDER_ERROR" - case configurationChanged = "PROVIDER_CONFIGURATION_CHANGED" - case stale = "PROVIDER_STALE" - case notReady = "PROVIDER_NOT_READY" +public enum ProviderEvent: Equatable { + case ready + case error(errorCode: ErrorCode? = nil, message: String? = nil) + case configurationChanged + case stale + case reconciling + case contextChanged } diff --git a/Sources/OpenFeature/Provider/ProviderStatus.swift b/Sources/OpenFeature/Provider/ProviderStatus.swift new file mode 100644 index 0000000..5c14224 --- /dev/null +++ b/Sources/OpenFeature/Provider/ProviderStatus.swift @@ -0,0 +1,10 @@ +import Foundation + +public enum ProviderStatus: String, CaseIterable { + case notReady = "PROVIDER_NOT_READY" + case ready = "PROVIDER_READY" + case error = "PROVIDER_ERROR" + case stale = "PROVIDER_STALE" + case fatal = "PROVIDER_FATAL" + case reconciling = "PROVIDER_RECONCILING" +} diff --git a/Sources/OpenFeature/exceptions/OpenFeatureError.swift b/Sources/OpenFeature/exceptions/OpenFeatureError.swift index 3919206..feb9395 100644 --- a/Sources/OpenFeature/exceptions/OpenFeatureError.swift +++ b/Sources/OpenFeature/exceptions/OpenFeatureError.swift @@ -9,7 +9,7 @@ public enum OpenFeatureError: Error, Equatable { case typeMismatchError case valueNotConvertableError case providerNotReadyError - case providerFatarError(message: String) + case providerFatalError(message: String) public func errorCode() -> ErrorCode { switch self { @@ -29,7 +29,7 @@ public enum OpenFeatureError: Error, Equatable { return .general case .providerNotReadyError: return .providerNotReady - case .providerFatarError: + case .providerFatalError: return .providerFatal } } @@ -54,7 +54,7 @@ extension OpenFeatureError: CustomStringConvertible { return "Could not convert value" case .providerNotReadyError: return "The value was resolved before the provider was ready" - case .providerFatarError(let message): + case .providerFatalError(let message): return "A fatal error occurred in the provider: \(message)" } } diff --git a/Tests/OpenFeatureTests/DeveloperExperienceTests.swift b/Tests/OpenFeatureTests/DeveloperExperienceTests.swift index 53fa65f..7c86234 100644 --- a/Tests/OpenFeatureTests/DeveloperExperienceTests.swift +++ b/Tests/OpenFeatureTests/DeveloperExperienceTests.swift @@ -11,8 +11,8 @@ final class DeveloperExperienceTests: XCTestCase { XCTAssertEqual(flagValue, "no-op") } - func testSimpleBooleanFlag() { - OpenFeatureAPI.shared.setProvider(provider: NoOpProvider()) + func testSimpleBooleanFlag() async { + await OpenFeatureAPI.shared.setProviderAndWait(provider: NoOpProvider()) let client = OpenFeatureAPI.shared.getClient() let flagValue = client.getValue(key: "test", defaultValue: false) @@ -20,12 +20,9 @@ final class DeveloperExperienceTests: XCTestCase { } func testObserveGlobalEvents() { - let notReadyExpectation = XCTestExpectation(description: "NotReady") let readyExpectation = XCTestExpectation(description: "Ready") var eventState = OpenFeatureAPI.shared.observe().sink { event in switch event { - case .notReady: - notReadyExpectation.fulfill() case .ready: readyExpectation.fulfill() default: @@ -46,15 +43,38 @@ final class DeveloperExperienceTests: XCTestCase { XCTAssertNotNil(eventState) } + func testSetEvaluationContext() async { + let contextChangedExpectation = XCTestExpectation(description: "Context Changed") + let reconcilingExpectation = XCTestExpectation(description: "Reconciling") + let observer = OpenFeatureAPI.shared.observe().sink { event in + switch event { + case .reconciling: + reconcilingExpectation.fulfill() + case .ready: + break + case .contextChanged: + contextChangedExpectation.fulfill() + default: + XCTFail("Unexpected event") + } + } + let semaphore = DispatchSemaphore(value: 0) + await OpenFeatureAPI.shared.setProviderAndWait(provider: StaggeredProvider(onContextSetSemaphore: semaphore)) + Task { + OpenFeatureAPI.shared.setEvaluationContext(evaluationContext: MutableContext(attributes: [:])) + } + await fulfillment(of: [reconcilingExpectation], timeout: 2) + semaphore.signal() + await fulfillment(of: [contextChangedExpectation], timeout: 2) + XCTAssertNotNil(observer) + } + func testSetProviderAndWait() async { - let notReadyExpectation = XCTestExpectation(description: "NotReady") let readyExpectation = XCTestExpectation(description: "Ready") let errorExpectation = XCTestExpectation(description: "Error") withExtendedLifetime( OpenFeatureAPI.shared.observe().sink { event in switch event { - case .notReady: - notReadyExpectation.fulfill() case .ready: readyExpectation.fulfill() case .error: @@ -66,15 +86,12 @@ final class DeveloperExperienceTests: XCTestCase { ) { let initCompleteExpectation = XCTestExpectation() - let eventHandler = EventHandler() - let provider = InjectableEventHandlerProvider(eventHandler: eventHandler) + let provider = DoSomethingProvider() Task { await OpenFeatureAPI.shared.setProviderAndWait(provider: provider) await fulfillment(of: [readyExpectation], timeout: 1) initCompleteExpectation.fulfill() } - wait(for: [notReadyExpectation], timeout: 1) - eventHandler.send(.ready) wait(for: [initCompleteExpectation], timeout: 1) let errorProviderExpectation = XCTestExpectation() @@ -84,14 +101,12 @@ final class DeveloperExperienceTests: XCTestCase { await fulfillment(of: [errorExpectation], timeout: 2) errorProviderExpectation.fulfill() } - - eventHandler.send(.error) wait(for: [errorProviderExpectation], timeout: 2) } } - func testClientHooks() { - OpenFeatureAPI.shared.setProvider(provider: NoOpProvider()) + func testClientHooks() async { + await OpenFeatureAPI.shared.setProviderAndWait(provider: NoOpProvider()) let client = OpenFeatureAPI.shared.getClient() let booleanHook = BooleanHookMock() @@ -111,8 +126,8 @@ final class DeveloperExperienceTests: XCTestCase { XCTAssertEqual(intHook.finallyAfterCalled, 1) } - func testEvalHooks() { - OpenFeatureAPI.shared.setProvider(provider: NoOpProvider()) + func testEvalHooks() async { + await OpenFeatureAPI.shared.setProviderAndWait(provider: NoOpProvider()) let client = OpenFeatureAPI.shared.getClient() let booleanHook = BooleanHookMock() @@ -132,8 +147,9 @@ final class DeveloperExperienceTests: XCTestCase { XCTAssertEqual(intHook.finallyAfterCalled, 1) } - func testBrokenProvider() { - OpenFeatureAPI.shared.setProvider(provider: AlwaysBrokenProvider()) + // TODO Gotten a few failuers on this test signaling potential race conditions + func testBrokenProvider() async { + await OpenFeatureAPI.shared.setProviderAndWait(provider: AlwaysBrokenProvider()) let client = OpenFeatureAPI.shared.getClient() let details = client.getDetails(key: "test", defaultValue: false) @@ -142,4 +158,15 @@ final class DeveloperExperienceTests: XCTestCase { XCTAssertEqual(details.errorMessage, "Could not find flag for key: test") XCTAssertEqual(details.reason, Reason.error.rawValue) } + + func testThrowingProvider() async { + await OpenFeatureAPI.shared.setProviderAndWait(provider: ThrowingProvider()) + let client = OpenFeatureAPI.shared.getClient() + + let details = client.getDetails(key: "test", defaultValue: false) + + XCTAssertEqual(details.errorCode, .providerFatal) + XCTAssertEqual(details.errorMessage, "A fatal error occurred in the provider: unknown") + XCTAssertEqual(details.reason, Reason.error.rawValue) + } } diff --git a/Tests/OpenFeatureTests/FlagEvaluationTests.swift b/Tests/OpenFeatureTests/FlagEvaluationTests.swift index 80d5003..9f78812 100644 --- a/Tests/OpenFeatureTests/FlagEvaluationTests.swift +++ b/Tests/OpenFeatureTests/FlagEvaluationTests.swift @@ -9,15 +9,14 @@ final class FlagEvaluationTests: XCTestCase { XCTAssertTrue(OpenFeatureAPI.shared === OpenFeatureAPI.shared) } - func testApiSetsProvider() { + func testApiSetsProvider() async { let provider = NoOpProvider() - OpenFeatureAPI.shared.setProvider(provider: provider) + await OpenFeatureAPI.shared.setProviderAndWait(provider: provider) XCTAssertTrue((OpenFeatureAPI.shared.getProvider() as? NoOpProvider) === provider) } - func testProviderMetadata() { - OpenFeatureAPI.shared.setProvider(provider: DoSomethingProvider()) - + func testProviderMetadata() async { + await OpenFeatureAPI.shared.setProviderAndWait(provider: DoSomethingProvider()) XCTAssertEqual(OpenFeatureAPI.shared.getProviderMetadata()?.name, DoSomethingProvider.name) } @@ -53,14 +52,11 @@ final class FlagEvaluationTests: XCTestCase { func testSimpleFlagEvaluation() { let provider = DoSomethingProvider() - let notReadyExpectation = XCTestExpectation(description: "NotReady") let readyExpectation = XCTestExpectation(description: "Ready") let errorExpectation = XCTestExpectation(description: "Error") let staleExpectation = XCTestExpectation(description: "Stale") - let eventState = provider.observe().sink { event in + let eventState = OpenFeatureAPI.shared.observe().sink { event in switch event { - case .notReady: - notReadyExpectation.fulfill() case .ready: readyExpectation.fulfill() case .error: @@ -72,7 +68,6 @@ final class FlagEvaluationTests: XCTestCase { } } - wait(for: [notReadyExpectation], timeout: 5) OpenFeatureAPI.shared.setProvider(provider: provider) wait(for: [readyExpectation], timeout: 5) let client = OpenFeatureAPI.shared.getClient() @@ -112,12 +107,9 @@ final class FlagEvaluationTests: XCTestCase { func testDetailedFlagEvaluation() async { let provider = DoSomethingProvider() - let notReadyExpectation = XCTestExpectation(description: "NotReady") let readyExpectation = XCTestExpectation(description: "Ready") - let eventState = provider.observe().sink { event in + let eventState = OpenFeatureAPI.shared.observe().sink { event in switch event { - case .notReady: - notReadyExpectation.fulfill() case .ready: readyExpectation.fulfill() default: @@ -175,12 +167,9 @@ final class FlagEvaluationTests: XCTestCase { func testHooksAreFired() async { let provider = NoOpProvider() - let notReadyExpectation = XCTestExpectation(description: "NotReady") let readyExpectation = XCTestExpectation(description: "Ready") - let eventState = provider.observe().sink { event in + let eventState = OpenFeatureAPI.shared.observe().sink { event in switch event { - case .notReady: - notReadyExpectation.fulfill() case .ready: readyExpectation.fulfill() default: @@ -209,14 +198,11 @@ final class FlagEvaluationTests: XCTestCase { func testBrokenProvider() { let provider = AlwaysBrokenProvider() - let notReadyExpectation = XCTestExpectation(description: "NotReady") let readyExpectation = XCTestExpectation(description: "Ready") let errorExpectation = XCTestExpectation(description: "Error") let staleExpectation = XCTestExpectation(description: "Stale") let eventState = provider.observe().sink { event in switch event { - case .notReady: - notReadyExpectation.fulfill() case .ready: readyExpectation.fulfill() case .error: @@ -248,9 +234,6 @@ final class FlagEvaluationTests: XCTestCase { let eventState = provider.observe().sink { event in switch event { - case .notReady: - // The provider starts in this state. - return case .error: fatalExpectation.fulfill() default: diff --git a/Tests/OpenFeatureTests/Helpers/AlwaysBrokenProvider.swift b/Tests/OpenFeatureTests/Helpers/AlwaysBrokenProvider.swift index c1acea0..94db64b 100644 --- a/Tests/OpenFeatureTests/Helpers/AlwaysBrokenProvider.swift +++ b/Tests/OpenFeatureTests/Helpers/AlwaysBrokenProvider.swift @@ -10,18 +10,18 @@ class AlwaysBrokenProvider: FeatureProvider { private let eventHandler = EventHandler() func onContextSet(oldContext: OpenFeature.EvaluationContext?, newContext: OpenFeature.EvaluationContext) { - eventHandler.send(.error) + eventHandler.send(.error()) } func initialize(initialContext: OpenFeature.EvaluationContext?) { - eventHandler.send(.error) + eventHandler.send(.error()) } func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws -> OpenFeature.ProviderEvaluation { if self.throwFatal { - throw OpenFeatureError.providerFatarError(message: "Always broken") + throw OpenFeatureError.providerFatalError(message: "Always broken") } throw OpenFeatureError.flagNotFoundError(key: key) } @@ -30,7 +30,7 @@ class AlwaysBrokenProvider: FeatureProvider { -> OpenFeature.ProviderEvaluation { if self.throwFatal { - throw OpenFeatureError.providerFatarError(message: "Always broken") + throw OpenFeatureError.providerFatalError(message: "Always broken") } throw OpenFeatureError.flagNotFoundError(key: key) } @@ -39,7 +39,7 @@ class AlwaysBrokenProvider: FeatureProvider { -> OpenFeature.ProviderEvaluation { if self.throwFatal { - throw OpenFeatureError.providerFatarError(message: "Always broken") + throw OpenFeatureError.providerFatalError(message: "Always broken") } throw OpenFeatureError.flagNotFoundError(key: key) } @@ -48,7 +48,7 @@ class AlwaysBrokenProvider: FeatureProvider { -> OpenFeature.ProviderEvaluation { if self.throwFatal { - throw OpenFeatureError.providerFatarError(message: "Always broken") + throw OpenFeatureError.providerFatalError(message: "Always broken") } throw OpenFeatureError.flagNotFoundError(key: key) } @@ -57,12 +57,12 @@ class AlwaysBrokenProvider: FeatureProvider { -> OpenFeature.ProviderEvaluation { if self.throwFatal { - throw OpenFeatureError.providerFatarError(message: "Always broken") + throw OpenFeatureError.providerFatalError(message: "Always broken") } throw OpenFeatureError.flagNotFoundError(key: key) } - func observe() -> AnyPublisher { + func observe() -> AnyPublisher { eventHandler.observe() } } diff --git a/Tests/OpenFeatureTests/Helpers/DoSomethingProvider.swift b/Tests/OpenFeatureTests/Helpers/DoSomethingProvider.swift index 0e29eaf..b2d339c 100644 --- a/Tests/OpenFeatureTests/Helpers/DoSomethingProvider.swift +++ b/Tests/OpenFeatureTests/Helpers/DoSomethingProvider.swift @@ -4,15 +4,12 @@ import OpenFeature class DoSomethingProvider: FeatureProvider { public static let name = "Something" - private let eventHandler = EventHandler(.notReady) - private var holdit: AnyCancellable? + private let eventHandler = EventHandler() func onContextSet(oldContext: OpenFeature.EvaluationContext?, newContext: OpenFeature.EvaluationContext) { - eventHandler.send(.ready) } func initialize(initialContext: OpenFeature.EvaluationContext?) { - eventHandler.send(.ready) } var hooks: [any OpenFeature.Hook] = [] @@ -59,7 +56,7 @@ class DoSomethingProvider: FeatureProvider { return ProviderEvaluation(value: .null, flagMetadata: DoSomethingProvider.flagMetadataMap) } - func observe() -> AnyPublisher { + func observe() -> AnyPublisher { eventHandler.observe() } diff --git a/Tests/OpenFeatureTests/Helpers/InjectableEventHandlerProvider.swift b/Tests/OpenFeatureTests/Helpers/InjectableEventHandlerProvider.swift deleted file mode 100644 index c498f80..0000000 --- a/Tests/OpenFeatureTests/Helpers/InjectableEventHandlerProvider.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Combine -import Foundation -import OpenFeature - -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) { - // Let the parent test control events via eventHandler - } - - func initialize(initialContext: OpenFeature.EvaluationContext?) { - // Let the parent test control events via eventHandler - } - - 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 - } -} diff --git a/Tests/OpenFeatureTests/Helpers/StaggeredProvider.swift b/Tests/OpenFeatureTests/Helpers/StaggeredProvider.swift new file mode 100644 index 0000000..83c0ca9 --- /dev/null +++ b/Tests/OpenFeatureTests/Helpers/StaggeredProvider.swift @@ -0,0 +1,79 @@ +import Combine +import Foundation +import OpenFeature + +class StaggeredProvider: FeatureProvider { + public static let name = "Something" + private let eventHandler = EventHandler() + private let onContextSetSemaphore: DispatchSemaphore? + + init(onContextSetSemaphore: DispatchSemaphore?) { + self.onContextSetSemaphore = onContextSetSemaphore + } + + func onContextSet(oldContext: OpenFeature.EvaluationContext?, newContext: OpenFeature.EvaluationContext) { + onContextSetSemaphore?.wait() + } + + func initialize(initialContext: OpenFeature.EvaluationContext?) { + } + + var hooks: [any OpenFeature.Hook] = [] + var metadata: OpenFeature.ProviderMetadata = DoMetadata() + + func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws + -> ProviderEvaluation< + Bool + > + { + return ProviderEvaluation(value: !defaultValue, flagMetadata: DoSomethingProvider.flagMetadataMap) + } + + func getStringEvaluation(key: String, defaultValue: String, context: EvaluationContext?) throws + -> ProviderEvaluation< + String + > + { + return ProviderEvaluation( + value: String(defaultValue.reversed()), flagMetadata: DoSomethingProvider.flagMetadataMap) + } + + func getIntegerEvaluation(key: String, defaultValue: Int64, context: EvaluationContext?) throws + -> ProviderEvaluation< + Int64 + > + { + return ProviderEvaluation(value: defaultValue * 100, flagMetadata: DoSomethingProvider.flagMetadataMap) + } + + func getDoubleEvaluation(key: String, defaultValue: Double, context: EvaluationContext?) throws + -> ProviderEvaluation< + Double + > + { + return ProviderEvaluation(value: defaultValue * 100, flagMetadata: DoSomethingProvider.flagMetadataMap) + } + + func getObjectEvaluation(key: String, defaultValue: Value, context: EvaluationContext?) throws + -> ProviderEvaluation< + Value + > + { + return ProviderEvaluation(value: .null, flagMetadata: DoSomethingProvider.flagMetadataMap) + } + + func observe() -> AnyPublisher { + eventHandler.observe() + } + + public struct DoMetadata: ProviderMetadata { + public var name: String? = DoSomethingProvider.name + } + + public static let flagMetadataMap = [ + "int-metadata": FlagMetadataValue.integer(99), + "double-metadata": FlagMetadataValue.double(98.4), + "string-metadata": FlagMetadataValue.string("hello-world"), + "boolean-metadata": FlagMetadataValue.boolean(true), + ] +} diff --git a/Tests/OpenFeatureTests/Helpers/ThrowingProvider.swift b/Tests/OpenFeatureTests/Helpers/ThrowingProvider.swift new file mode 100644 index 0000000..a1b8b1b --- /dev/null +++ b/Tests/OpenFeatureTests/Helpers/ThrowingProvider.swift @@ -0,0 +1,58 @@ +import Combine +import Foundation + +@testable import OpenFeature + +class ThrowingProvider: FeatureProvider { + var metadata: ProviderMetadata = ThrowingProviderMetadata() + var hooks: [any Hook] = [] + private let eventHandler = EventHandler() + + func onContextSet(oldContext: OpenFeature.EvaluationContext?, newContext: OpenFeature.EvaluationContext) throws { + throw OpenFeatureError.providerFatalError(message: "Wrong credentials") + } + + func initialize(initialContext: OpenFeature.EvaluationContext?) throws { + throw OpenFeatureError.providerFatalError(message: "Wrong credentials") + } + + func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws + -> OpenFeature.ProviderEvaluation + { + throw OpenFeatureError.flagNotFoundError(key: key) + } + + func getStringEvaluation(key: String, defaultValue: String, context: EvaluationContext?) throws + -> OpenFeature.ProviderEvaluation + { + throw OpenFeatureError.flagNotFoundError(key: key) + } + + func getIntegerEvaluation(key: String, defaultValue: Int64, context: EvaluationContext?) throws + -> OpenFeature.ProviderEvaluation + { + throw OpenFeatureError.flagNotFoundError(key: key) + } + + func getDoubleEvaluation(key: String, defaultValue: Double, context: EvaluationContext?) throws + -> OpenFeature.ProviderEvaluation + { + throw OpenFeatureError.flagNotFoundError(key: key) + } + + func getObjectEvaluation(key: String, defaultValue: OpenFeature.Value, context: EvaluationContext?) throws + -> OpenFeature.ProviderEvaluation + { + throw OpenFeatureError.flagNotFoundError(key: key) + } + + func observe() -> AnyPublisher { + eventHandler.observe() + } +} + +extension ThrowingProvider { + struct ThrowingProviderMetadata: ProviderMetadata { + var name: String? = "test" + } +} diff --git a/Tests/OpenFeatureTests/HookSpecTests.swift b/Tests/OpenFeatureTests/HookSpecTests.swift index 67cdf68..66c1346 100644 --- a/Tests/OpenFeatureTests/HookSpecTests.swift +++ b/Tests/OpenFeatureTests/HookSpecTests.swift @@ -6,12 +6,9 @@ import XCTest final class HookSpecTests: XCTestCase { func testNoErrorHookCalled() { let provider = NoOpProvider() - let notReadyExpectation = XCTestExpectation(description: "NotReady") let readyExpectation = XCTestExpectation(description: "Ready") - let eventState = provider.observe().sink { event in + let eventState = OpenFeatureAPI.shared.observe().sink { event in switch event { - case .notReady: - notReadyExpectation.fulfill() case .ready: readyExpectation.fulfill() default: @@ -39,13 +36,10 @@ final class HookSpecTests: XCTestCase { func testErrorHookButNoAfterCalled() { let provider = AlwaysBrokenProvider() - let notReadyExpectation = XCTestExpectation(description: "NotReady") let readyExpectation = XCTestExpectation(description: "Ready") let errorExpectation = XCTestExpectation(description: "Error") let eventState = provider.observe().sink { event in switch event { - case .notReady: - notReadyExpectation.fulfill() case .ready: readyExpectation.fulfill() case .error: @@ -81,12 +75,9 @@ final class HookSpecTests: XCTestCase { let providerMock = NoOpProviderMock(hooks: [ BooleanHookMock(prefix: "provider", addEval: addEval) ]) - let notReadyExpectation = XCTestExpectation(description: "NotReady") let readyExpectation = XCTestExpectation(description: "Ready") - let eventState = providerMock.observe().sink { event in + let eventState = OpenFeatureAPI.shared.observe().sink { event in switch event { - case .notReady: - notReadyExpectation.fulfill() case .ready: readyExpectation.fulfill() default: diff --git a/Tests/OpenFeatureTests/ProviderEventsTests.swift b/Tests/OpenFeatureTests/ProviderEventsTests.swift deleted file mode 100644 index 4e4f09d..0000000 --- a/Tests/OpenFeatureTests/ProviderEventsTests.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -import OpenFeature -import XCTest - -final class ProviderEventsTests: XCTestCase { - let provider = DoSomethingProvider() - - func testReadyEventSent() { - let readyExpectation = XCTestExpectation(description: "Ready") - let eventState = - provider - .observe() - .filter { event in - event == ProviderEvent.ready - } - .sink { _ in - readyExpectation.fulfill() - } - OpenFeatureAPI.shared.setProvider(provider: provider) - wait(for: [readyExpectation], timeout: 5) - XCTAssertNotNil(eventState) - } -}