diff --git a/Sources/OpenFeature/Client.swift b/Sources/OpenFeature/Client.swift index 083f28e..c4783fc 100644 --- a/Sources/OpenFeature/Client.swift +++ b/Sources/OpenFeature/Client.swift @@ -11,4 +11,15 @@ public protocol Client: Features { /// Hooks are run in the order they're added in the before stage. They are run in reverse order for all /// other stages. func addHooks(_ hooks: any Hook...) + + /// Add a handler for a particular provider event + /// - Parameter observer: The object observing the event. + /// - Parameter selector: The selector to call for this event. + /// - Parameter event: The event to listen for. + func addHandler(observer: Any, selector: Selector, event: ProviderEvent) + + /// Remove a handler for a particular provider event + /// - Parameter observer: The object observing the event. + /// - Parameter event: The event being listened to. + func removeHandler(observer: Any, event: ProviderEvent) } diff --git a/Sources/OpenFeature/OpenFeatureAPI.swift b/Sources/OpenFeature/OpenFeatureAPI.swift index 080d0f6..a5535a3 100644 --- a/Sources/OpenFeature/OpenFeatureAPI.swift +++ b/Sources/OpenFeature/OpenFeatureAPI.swift @@ -7,22 +7,25 @@ public class OpenFeatureAPI { private var _context: EvaluationContext? private(set) var hooks: [any Hook] = [] + private let providerNotificationCentre = NotificationCenter() + /// The ``OpenFeatureAPI`` singleton static public let shared = OpenFeatureAPI() public init() { } - public func setProvider(provider: FeatureProvider) async { - await self.setProvider(provider: provider, initialContext: nil) + public func setProvider(provider: FeatureProvider) { + self.setProvider(provider: provider, initialContext: nil) } - public func setProvider(provider: FeatureProvider, initialContext: EvaluationContext?) async { + public func setProvider(provider: FeatureProvider, initialContext: EvaluationContext?) { self._provider = provider if let context = initialContext { self._context = context } - await provider.initialize(initialContext: self._context) + + provider.initialize(initialContext: self._context) } public func getProvider() -> FeatureProvider? { @@ -33,9 +36,10 @@ public class OpenFeatureAPI { self._provider = nil } - public func setEvaluationContext(evaluationContext: EvaluationContext) async { + public func setEvaluationContext(evaluationContext: EvaluationContext) { + let oldContext = self._context self._context = evaluationContext - await getProvider()?.onContextSet(oldContext: self._context, newContext: evaluationContext) + getProvider()?.onContextSet(oldContext: oldContext, newContext: evaluationContext) } public func getEvaluationContext() -> EvaluationContext? { @@ -62,3 +66,40 @@ public class OpenFeatureAPI { self.hooks.removeAll() } } + +// MARK: Provider Events + +extension OpenFeatureAPI { + public func addHandler(observer: Any, selector: Selector, event: ProviderEvent) { + providerNotificationCentre.addObserver( + observer, + selector: selector, + name: event.notification, + object: nil + ) + } + + public func removeHandler(observer: Any, event: ProviderEvent) { + providerNotificationCentre.removeObserver(observer, name: event.notification, object: nil) + } + + public func emitEvent( + _ event: ProviderEvent, + provider: FeatureProvider, + error: Error? = nil, + details: [AnyHashable: Any]? = nil + ) { + var userInfo: [AnyHashable: Any] = [:] + userInfo[providerEventDetailsKeyProvider] = provider + + if let error { + userInfo[providerEventDetailsKeyError] = error + } + + if let details { + userInfo.merge(details) { $1 } // Merge, keeping value from `details` if any conflicts + } + + providerNotificationCentre.post(name: event.notification, object: nil, userInfo: userInfo) + } +} diff --git a/Sources/OpenFeature/OpenFeatureClient.swift b/Sources/OpenFeature/OpenFeatureClient.swift index cdd933f..e5fd63e 100644 --- a/Sources/OpenFeature/OpenFeatureClient.swift +++ b/Sources/OpenFeature/OpenFeatureClient.swift @@ -14,11 +14,15 @@ public class OpenFeatureClient: Client { private var hookSupport = HookSupport() private var logger = Logger() + private let providerNotificationCentre = NotificationCenter() + public init(openFeatureApi: OpenFeatureAPI, name: String?, version: String?) { self.openFeatureApi = openFeatureApi self.name = name self.version = version self.metadata = Metadata(name: name) + + subscribeToAllProviderEvents() } public func addHooks(_ hooks: any Hook...) { @@ -196,3 +200,42 @@ extension OpenFeatureClient { throw OpenFeatureError.generalError(message: "Unable to match default value type with flag value type") } } + +// MARK: Events + +extension OpenFeatureClient { + public func subscribeToAllProviderEvents() { + ProviderEvent.allCases.forEach { event in + OpenFeatureAPI.shared.addHandler( + observer: self, + selector: #selector(handleProviderEvent(notification:)), + event: event) + } + } + + public func unsubscribeFromAllProviderEvents() { + ProviderEvent.allCases.forEach { event in + OpenFeatureAPI.shared.removeHandler(observer: self, event: event) + } + } + + @objc public func handleProviderEvent(notification: Notification) { + var userInfo: [AnyHashable: Any] = notification.userInfo ?? [:] + userInfo[providerEventDetailsKeyClient] = self + + providerNotificationCentre.post(name: notification.name, object: nil, userInfo: userInfo) + } + + public func addHandler(observer: Any, selector: Selector, event: ProviderEvent) { + providerNotificationCentre.addObserver( + observer, + selector: selector, + name: event.notification, + object: nil + ) + } + + public func removeHandler(observer: Any, event: ProviderEvent) { + providerNotificationCentre.removeObserver(observer, name: event.notification, object: nil) + } +} diff --git a/Sources/OpenFeature/FeatureProvider.swift b/Sources/OpenFeature/Provider/FeatureProvider.swift similarity index 93% rename from Sources/OpenFeature/FeatureProvider.swift rename to Sources/OpenFeature/Provider/FeatureProvider.swift index 0b2ca5a..44a5c7b 100644 --- a/Sources/OpenFeature/FeatureProvider.swift +++ b/Sources/OpenFeature/Provider/FeatureProvider.swift @@ -6,10 +6,10 @@ public protocol FeatureProvider { var metadata: ProviderMetadata { get } /// Called by OpenFeatureAPI whenever the new Provider is registered - func initialize(initialContext: EvaluationContext?) async + func initialize(initialContext: EvaluationContext?) /// Called by OpenFeatureAPI whenever a new EvaluationContext is set by the application - func onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) async + func onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws -> ProviderEvaluation< diff --git a/Sources/OpenFeature/NoOpProvider.swift b/Sources/OpenFeature/Provider/NoOpProvider.swift similarity index 90% rename from Sources/OpenFeature/NoOpProvider.swift rename to Sources/OpenFeature/Provider/NoOpProvider.swift index f5d41bf..7727d9e 100644 --- a/Sources/OpenFeature/NoOpProvider.swift +++ b/Sources/OpenFeature/Provider/NoOpProvider.swift @@ -4,15 +4,20 @@ import Foundation class NoOpProvider: FeatureProvider { public static let passedInDefault = "Passed in default" + public enum Mode { + case normal + case error(message: String) + } + var metadata: ProviderMetadata = NoOpMetadata(name: "No-op provider") var hooks: [any Hook] = [] func onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { - // no-op + OpenFeatureAPI.shared.emitEvent(.configurationChanged, provider: self) } func initialize(initialContext: EvaluationContext?) { - // no-op + OpenFeatureAPI.shared.emitEvent(.ready, provider: self) } func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws diff --git a/Sources/OpenFeature/ProviderEvaluation.swift b/Sources/OpenFeature/Provider/ProviderEvaluation.swift similarity index 100% rename from Sources/OpenFeature/ProviderEvaluation.swift rename to Sources/OpenFeature/Provider/ProviderEvaluation.swift diff --git a/Sources/OpenFeature/Provider/ProviderEvents.swift b/Sources/OpenFeature/Provider/ProviderEvents.swift new file mode 100644 index 0000000..133ec7a --- /dev/null +++ b/Sources/OpenFeature/Provider/ProviderEvents.swift @@ -0,0 +1,16 @@ +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" + + var notification: NSNotification.Name { + NSNotification.Name(rawValue) + } +} diff --git a/Sources/OpenFeature/ProviderMetadata.swift b/Sources/OpenFeature/Provider/ProviderMetadata.swift similarity index 100% rename from Sources/OpenFeature/ProviderMetadata.swift rename to Sources/OpenFeature/Provider/ProviderMetadata.swift diff --git a/Tests/OpenFeatureTests/DeveloperExperienceTests.swift b/Tests/OpenFeatureTests/DeveloperExperienceTests.swift index ea2f41a..908af80 100644 --- a/Tests/OpenFeatureTests/DeveloperExperienceTests.swift +++ b/Tests/OpenFeatureTests/DeveloperExperienceTests.swift @@ -11,16 +11,16 @@ final class DeveloperExperienceTests: XCTestCase { XCTAssertEqual(flagValue, "no-op") } - func testSimpleBooleanFlag() async { - await OpenFeatureAPI.shared.setProvider(provider: NoOpProvider()) + func testSimpleBooleanFlag() { + OpenFeatureAPI.shared.setProvider(provider: NoOpProvider()) let client = OpenFeatureAPI.shared.getClient() let flagValue = client.getValue(key: "test", defaultValue: false) XCTAssertFalse(flagValue) } - func testClientHooks() async { - await OpenFeatureAPI.shared.setProvider(provider: NoOpProvider()) + func testClientHooks() { + OpenFeatureAPI.shared.setProvider(provider: NoOpProvider()) let client = OpenFeatureAPI.shared.getClient() let booleanHook = BooleanHookMock() @@ -40,8 +40,8 @@ final class DeveloperExperienceTests: XCTestCase { XCTAssertEqual(intHook.finallyAfterCalled, 1) } - func testEvalHooks() async { - await OpenFeatureAPI.shared.setProvider(provider: NoOpProvider()) + func testEvalHooks() { + OpenFeatureAPI.shared.setProvider(provider: NoOpProvider()) let client = OpenFeatureAPI.shared.getClient() let booleanHook = BooleanHookMock() @@ -61,8 +61,8 @@ final class DeveloperExperienceTests: XCTestCase { XCTAssertEqual(intHook.finallyAfterCalled, 1) } - func testBrokenProvider() async { - await OpenFeatureAPI.shared.setProvider(provider: AlwaysBrokenProvider()) + func testBrokenProvider() { + OpenFeatureAPI.shared.setProvider(provider: AlwaysBrokenProvider()) let client = OpenFeatureAPI.shared.getClient() let details = client.getDetails(key: "test", defaultValue: false) diff --git a/Tests/OpenFeatureTests/FlagEvaluationTests.swift b/Tests/OpenFeatureTests/FlagEvaluationTests.swift index 2e22206..c9da8f0 100644 --- a/Tests/OpenFeatureTests/FlagEvaluationTests.swift +++ b/Tests/OpenFeatureTests/FlagEvaluationTests.swift @@ -4,19 +4,31 @@ import XCTest @testable import OpenFeature final class FlagEvaluationTests: XCTestCase { + override func setUp() { + super.setUp() + + OpenFeatureAPI.shared.addHandler( + observer: self, selector: #selector(readyEventEmitted(notification:)), event: .ready + ) + + OpenFeatureAPI.shared.addHandler( + observer: self, selector: #selector(errorEventEmitted(notification:)), event: .error + ) + } + func testSingletonPersists() { XCTAssertTrue(OpenFeatureAPI.shared === OpenFeatureAPI.shared) } - func testApiSetsProvider() async { + func testApiSetsProvider() { let provider = NoOpProvider() - await OpenFeatureAPI.shared.setProvider(provider: provider) + OpenFeatureAPI.shared.setProvider(provider: provider) XCTAssertTrue((OpenFeatureAPI.shared.getProvider() as? NoOpProvider) === provider) } - func testProviderMetadata() async { - await OpenFeatureAPI.shared.setProvider(provider: DoSomethingProvider()) + func testProviderMetadata() { + OpenFeatureAPI.shared.setProvider(provider: DoSomethingProvider()) XCTAssertEqual(OpenFeatureAPI.shared.getProviderMetadata()?.name, DoSomethingProvider.name) } @@ -51,8 +63,10 @@ final class FlagEvaluationTests: XCTestCase { XCTAssertEqual(client.hooks.count, 2) } - func testSimpleFlagEvaluation() async { - await OpenFeatureAPI.shared.setProvider(provider: DoSomethingProvider()) + func testSimpleFlagEvaluation() { + OpenFeatureAPI.shared.setProvider(provider: DoSomethingProvider()) + wait(for: [readyExpectation], timeout: 5) + let client = OpenFeatureAPI.shared.getClient() let key = "key" @@ -89,7 +103,9 @@ final class FlagEvaluationTests: XCTestCase { } func testDetailedFlagEvaluation() async { - await OpenFeatureAPI.shared.setProvider(provider: DoSomethingProvider()) + OpenFeatureAPI.shared.setProvider(provider: DoSomethingProvider()) + wait(for: [readyExpectation], timeout: 5) + let client = OpenFeatureAPI.shared.getClient() let key = "key" @@ -132,7 +148,9 @@ final class FlagEvaluationTests: XCTestCase { } func testHooksAreFired() async { - await OpenFeatureAPI.shared.setProvider(provider: NoOpProvider()) + OpenFeatureAPI.shared.setProvider(provider: NoOpProvider()) + wait(for: [readyExpectation], timeout: 5) + let client = OpenFeatureAPI.shared.getClient() let clientHook = BooleanHookMock() @@ -148,8 +166,10 @@ final class FlagEvaluationTests: XCTestCase { XCTAssertEqual(invocationHook.beforeCalled, 1) } - func testBrokenProvider() async { - await OpenFeatureAPI.shared.setProvider(provider: AlwaysBrokenProvider()) + func testBrokenProvider() { + OpenFeatureAPI.shared.setProvider(provider: AlwaysBrokenProvider()) + wait(for: [errorExpectation], timeout: 5) + let client = OpenFeatureAPI.shared.getClient() XCTAssertFalse(client.getValue(key: "testkey", defaultValue: false)) @@ -167,4 +187,17 @@ final class FlagEvaluationTests: XCTestCase { let client = OpenFeatureAPI.shared.getClient(name: "test", version: nil) XCTAssertEqual(client.metadata.name, "test") } + + // MARK: Event Handlers + let readyExpectation = XCTestExpectation(description: "Ready") + + func readyEventEmitted(notification: NSNotification) { + readyExpectation.fulfill() + } + + let errorExpectation = XCTestExpectation(description: "Error") + + func errorEventEmitted(notification: NSNotification) { + errorExpectation.fulfill() + } } diff --git a/Tests/OpenFeatureTests/Helpers/AlwaysBrokenProvider.swift b/Tests/OpenFeatureTests/Helpers/AlwaysBrokenProvider.swift index 433fa55..68057df 100644 --- a/Tests/OpenFeatureTests/Helpers/AlwaysBrokenProvider.swift +++ b/Tests/OpenFeatureTests/Helpers/AlwaysBrokenProvider.swift @@ -7,11 +7,13 @@ class AlwaysBrokenProvider: FeatureProvider { var hooks: [any Hook] = [] func onContextSet(oldContext: OpenFeature.EvaluationContext?, newContext: OpenFeature.EvaluationContext) { - // no-op + let error = OpenFeatureError.generalError(message: "Always Fails") + OpenFeatureAPI.shared.emitEvent(.error, provider: self, error: error) } func initialize(initialContext: OpenFeature.EvaluationContext?) { - // no-op + let error = OpenFeatureError.generalError(message: "Always Fails") + OpenFeatureAPI.shared.emitEvent(.error, provider: self, error: error) } func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws diff --git a/Tests/OpenFeatureTests/Helpers/DoSomethingProvider.swift b/Tests/OpenFeatureTests/Helpers/DoSomethingProvider.swift index f8cac77..acbfff2 100644 --- a/Tests/OpenFeatureTests/Helpers/DoSomethingProvider.swift +++ b/Tests/OpenFeatureTests/Helpers/DoSomethingProvider.swift @@ -5,11 +5,11 @@ class DoSomethingProvider: FeatureProvider { public static let name = "Something" func onContextSet(oldContext: OpenFeature.EvaluationContext?, newContext: OpenFeature.EvaluationContext) { - // no-op + OpenFeatureAPI.shared.emitEvent(.configurationChanged, provider: self) } func initialize(initialContext: OpenFeature.EvaluationContext?) { - // no-op + OpenFeatureAPI.shared.emitEvent(.ready, provider: self) } var hooks: [any OpenFeature.Hook] = [] diff --git a/Tests/OpenFeatureTests/HookSpecTests.swift b/Tests/OpenFeatureTests/HookSpecTests.swift index 07b7713..9fe9304 100644 --- a/Tests/OpenFeatureTests/HookSpecTests.swift +++ b/Tests/OpenFeatureTests/HookSpecTests.swift @@ -4,12 +4,24 @@ import XCTest @testable import OpenFeature final class HookSpecTests: XCTestCase { - func testNoErrorHookCalled() async { - await OpenFeatureAPI.shared.setProvider(provider: NoOpProvider()) - let client = OpenFeatureAPI.shared.getClient() + override func setUp() { + super.setUp() - let hook = BooleanHookMock() + OpenFeatureAPI.shared.addHandler( + observer: self, selector: #selector(readyEventEmitted(notification:)), event: .ready + ) + + OpenFeatureAPI.shared.addHandler( + observer: self, selector: #selector(errorEventEmitted(notification:)), event: .error + ) + } + func testNoErrorHookCalled() { + OpenFeatureAPI.shared.setProvider(provider: NoOpProvider()) + wait(for: [readyExpectation], timeout: 5) + + let client = OpenFeatureAPI.shared.getClient() + let hook = BooleanHookMock() let feo = FlagEvaluationOptions(hooks: [hook]) _ = client.getValue( @@ -23,8 +35,10 @@ final class HookSpecTests: XCTestCase { XCTAssertEqual(hook.finallyAfterCalled, 1) } - func testErrorHookButNoAfterCalled() async { - await OpenFeatureAPI.shared.setProvider(provider: AlwaysBrokenProvider()) + func testErrorHookButNoAfterCalled() { + OpenFeatureAPI.shared.setProvider(provider: AlwaysBrokenProvider()) + wait(for: [errorExpectation], timeout: 5) + let client = OpenFeatureAPI.shared.getClient() let hook = BooleanHookMock() @@ -39,7 +53,7 @@ final class HookSpecTests: XCTestCase { XCTAssertEqual(hook.finallyAfterCalled, 1) } - func testHookEvaluationOrder() async { + func testHookEvaluationOrder() { var evalOrder: [String] = [] let addEval: (String) -> Void = { eval in evalOrder.append(eval) @@ -48,7 +62,9 @@ final class HookSpecTests: XCTestCase { let providerMock = NoOpProviderMock(hooks: [ BooleanHookMock(prefix: "provider", addEval: addEval) ]) - await OpenFeatureAPI.shared.setProvider(provider: providerMock) + OpenFeatureAPI.shared.setProvider(provider: providerMock) + wait(for: [readyExpectation], timeout: 5) + OpenFeatureAPI.shared.addHooks(hooks: BooleanHookMock(prefix: "api", addEval: addEval)) let client = OpenFeatureAPI.shared.getClient() client.addHooks(BooleanHookMock(prefix: "client", addEval: addEval)) @@ -75,6 +91,19 @@ final class HookSpecTests: XCTestCase { "api finallyAfter", ]) } + + // MARK: Event Handlers + let readyExpectation = XCTestExpectation(description: "Ready") + + func readyEventEmitted(notification: NSNotification) { + readyExpectation.fulfill() + } + + let errorExpectation = XCTestExpectation(description: "Error") + + func errorEventEmitted(notification: NSNotification) { + errorExpectation.fulfill() + } } extension HookSpecTests { diff --git a/Tests/OpenFeatureTests/OpenFeatureClientTests.swift b/Tests/OpenFeatureTests/OpenFeatureClientTests.swift index 133e551..6c0b38f 100644 --- a/Tests/OpenFeatureTests/OpenFeatureClientTests.swift +++ b/Tests/OpenFeatureTests/OpenFeatureClientTests.swift @@ -4,8 +4,8 @@ import XCTest @testable import OpenFeature final class OpenFeatureClientTests: XCTestCase { - func testShouldNowThrowIfHookHasDifferentTypeArgument() async { - await OpenFeatureAPI.shared.setProvider(provider: DoSomethingProvider()) + func testShouldNowThrowIfHookHasDifferentTypeArgument() { + OpenFeatureAPI.shared.setProvider(provider: DoSomethingProvider()) OpenFeatureAPI.shared.addHooks(hooks: BooleanHookMock()) let client = OpenFeatureAPI.shared.getClient() @@ -21,4 +21,46 @@ final class OpenFeatureClientTests: XCTestCase { let doubleDetails = client.getDetails(key: "key", defaultValue: 123.1) XCTAssertEqual(doubleDetails.value, 12_310) } + + func testProviderEvents() { + setupExpectations() + + let provider = DoSomethingProvider() + OpenFeatureAPI.shared.setProvider(provider: provider) + + let client = OpenFeatureAPI.shared.getClient() + ProviderEvent.allCases.forEach { event in + client.addHandler(observer: self, selector: #selector(eventEmitted(notification:)), event: event) + + OpenFeatureAPI.shared.emitEvent(event, provider: provider) + if let expectation = eventExpectations[event] { + wait(for: [expectation], timeout: 5) + } else { + XCTFail("No expectation for provider event: \(event)") + } + } + } + + // MARK: Event Handlers + private var eventExpectations: [ProviderEvent: XCTestExpectation] = [:] + + func setupExpectations() { + ProviderEvent.allCases.forEach { event in + eventExpectations[event] = XCTestExpectation(description: event.rawValue) + } + } + + func eventEmitted(notification: NSNotification) { + guard let providerEvent = ProviderEvent(rawValue: notification.name.rawValue) else { + XCTFail("Unexpected provider event: \(notification.name)") + return + } + + guard let expectation = eventExpectations[providerEvent] else { + XCTFail("No expectation for provider event: \(providerEvent)") + return + } + + expectation.fulfill() + } } diff --git a/Tests/OpenFeatureTests/ProviderEventsTests.swift b/Tests/OpenFeatureTests/ProviderEventsTests.swift new file mode 100644 index 0000000..ed8218b --- /dev/null +++ b/Tests/OpenFeatureTests/ProviderEventsTests.swift @@ -0,0 +1,30 @@ +import Foundation +import OpenFeature +import XCTest + +final class ProviderEventsTests: XCTestCase { + let provider = DoSomethingProvider() + + func testReadyEventEmitted() { + OpenFeatureAPI.shared.addHandler( + observer: self, selector: #selector(readyEventEmitted(notification:)), event: .ready + ) + + OpenFeatureAPI.shared.setProvider(provider: provider) + wait(for: [readyExpectation], timeout: 5) + } + + // MARK: Event Handlers + let readyExpectation = XCTestExpectation(description: "Ready") + + func readyEventEmitted(notification: NSNotification) { + readyExpectation.fulfill() + + let maybeProvider = notification.userInfo?[providerEventDetailsKeyProvider] + guard let eventProvider = maybeProvider as? DoSomethingProvider else { + XCTFail("Provider not passed in notification") + return + } + XCTAssertEqual(eventProvider.metadata.name, provider.metadata.name) + } +}