From d34022fc9a7474610422041c709c815d0881af0d Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Wed, 4 Sep 2024 16:39:54 +0200 Subject: [PATCH 1/9] feat!: Align ProviderState and ProviderEvent with Spec Signed-off-by: Fabrizio Demaria --- Sources/OpenFeature/EventHandler.swift | 17 +- Sources/OpenFeature/OpenFeatureAPI.swift | 155 +++++++++++++----- 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 | 66 +++++--- .../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, 387 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..b0a3e16 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,74 @@ 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 { + 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 + } } 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 +108,72 @@ public class OpenFeatureAPI { self.hooks.removeAll() } - public func observe() -> AnyPublisher { + 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..13c36e8 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,8 @@ final class DeveloperExperienceTests: XCTestCase { XCTAssertEqual(intHook.finallyAfterCalled, 1) } - func testBrokenProvider() { - OpenFeatureAPI.shared.setProvider(provider: AlwaysBrokenProvider()) + func testBrokenProvider() async { + await OpenFeatureAPI.shared.setProviderAndWait(provider: AlwaysBrokenProvider()) let client = OpenFeatureAPI.shared.getClient() let details = client.getDetails(key: "test", defaultValue: false) @@ -142,4 +157,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) - } -} From 56ed33381881bb7cec9faa8c0c367b8aa8d61e51 Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Thu, 19 Dec 2024 10:22:06 +0100 Subject: [PATCH 2/9] docs: Update spec version and tracking Signed-off-by: Fabrizio Demaria --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d315299..b38cc67 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- + Specification @@ -90,11 +90,12 @@ Task { ## 🌟 Features -| Status | Features | Description | -| ------ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Status | Features | Description | +| ------ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. | | ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). | | ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. | +| ❌ | [Tracking](#tracking) | Associate user actions with feature flag evaluations. | | ❌ | [Logging](#logging) | Integrate with popular logging packages. | | ❌ | [Named clients](#named-clients) | Utilize multiple providers in a single application. | | ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. | @@ -153,6 +154,10 @@ _ = client.getValue( defaultValue: false, options: FlagEvaluationOptions(hooks: [ExampleHook()])) ``` +### Tracking + +Tracking is not yet available in the iOS SDK. + ### Logging Logging customization is not yet available in the iOS SDK. From f1fe08f37dcd7c474429afc61192aa64cd3e1374 Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Thu, 19 Dec 2024 10:45:32 +0100 Subject: [PATCH 3/9] feat: Add setEvaluationContextAndWait Signed-off-by: Fabrizio Demaria --- Sources/OpenFeature/OpenFeatureAPI.swift | 23 +++++++++++++++++-- .../DeveloperExperienceTests.swift | 17 ++++++++++++++ .../Helpers/StaggeredProvider.swift | 5 ++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/Sources/OpenFeature/OpenFeatureAPI.swift b/Sources/OpenFeature/OpenFeatureAPI.swift index b0a3e16..30c912e 100644 --- a/Sources/OpenFeature/OpenFeatureAPI.swift +++ b/Sources/OpenFeature/OpenFeatureAPI.swift @@ -32,7 +32,7 @@ public class OpenFeatureAPI { /** Set provider and calls its `initialize`. - This async function returns when the initalize from the provider is completed. + This async function returns when the `initalize` from the provider is completed. */ public func setProviderAndWait(provider: FeatureProvider, initialContext: EvaluationContext?) async { await withCheckedContinuation { continuation in @@ -55,7 +55,7 @@ public class OpenFeatureAPI { /** Set provider and calls its `initialize`. - This async function returns when the initalize from the provider is completed. + This async function returns when the `initalize` from the provider is completed. */ public func setProviderAndWait(provider: FeatureProvider) async { await setProviderAndWait(provider: provider, initialContext: nil) @@ -72,6 +72,10 @@ public class OpenFeatureAPI { } } + /** + Set evaluation context and calls the provider's `onContextSet` in a background thread. + Readiness can be determined from `getState` or listening for `contextChanged` event. + */ public func setEvaluationContext(evaluationContext: EvaluationContext) { queue.async { Task { @@ -80,6 +84,21 @@ public class OpenFeatureAPI { } } + /** + Set evaluation context and calls the provider's `onContextSet`. + This async function returns when the `onContextSet` from the provider is completed. + */ + public func setEvaluationContextAndWait(evaluationContext: EvaluationContext) async { + await withCheckedContinuation { continuation in + queue.async { + Task { + await self.updateContext(evaluationContext: evaluationContext) + continuation.resume() + } + } + } + } + public func getEvaluationContext() -> EvaluationContext? { return self.evaluationContext } diff --git a/Tests/OpenFeatureTests/DeveloperExperienceTests.swift b/Tests/OpenFeatureTests/DeveloperExperienceTests.swift index 13c36e8..1056480 100644 --- a/Tests/OpenFeatureTests/DeveloperExperienceTests.swift +++ b/Tests/OpenFeatureTests/DeveloperExperienceTests.swift @@ -69,6 +69,23 @@ final class DeveloperExperienceTests: XCTestCase { XCTAssertNotNil(observer) } + func testSetEvaluationContextAndWait() async { + let reconcilingExpectation = XCTestExpectation(description: "Reconciling") + let semaphore = DispatchSemaphore(value: 0) + let ctx = MutableContext(attributes: ["test": .string("value")]) + let provider = StaggeredProvider(onContextSetSemaphore: semaphore) + await OpenFeatureAPI.shared.setProviderAndWait(provider: provider) + Task { + await OpenFeatureAPI.shared.setEvaluationContextAndWait(evaluationContext: ctx) + reconcilingExpectation.fulfill() + } + XCTAssertEqual(provider.activeContext.asMap(), MutableContext().asMap()) + semaphore.signal() + await fulfillment(of: [reconcilingExpectation], timeout: 2) + XCTAssertEqual(OpenFeatureAPI.shared.getEvaluationContext()?.asMap(), ctx.asMap()) + XCTAssertEqual(provider.activeContext.asMap(), ctx.asMap()) + } + func testSetProviderAndWait() async { let readyExpectation = XCTestExpectation(description: "Ready") let errorExpectation = XCTestExpectation(description: "Error") diff --git a/Tests/OpenFeatureTests/Helpers/StaggeredProvider.swift b/Tests/OpenFeatureTests/Helpers/StaggeredProvider.swift index 83c0ca9..b867341 100644 --- a/Tests/OpenFeatureTests/Helpers/StaggeredProvider.swift +++ b/Tests/OpenFeatureTests/Helpers/StaggeredProvider.swift @@ -6,6 +6,7 @@ class StaggeredProvider: FeatureProvider { public static let name = "Something" private let eventHandler = EventHandler() private let onContextSetSemaphore: DispatchSemaphore? + public var activeContext: EvaluationContext = MutableContext() init(onContextSetSemaphore: DispatchSemaphore?) { self.onContextSetSemaphore = onContextSetSemaphore @@ -13,9 +14,13 @@ class StaggeredProvider: FeatureProvider { func onContextSet(oldContext: OpenFeature.EvaluationContext?, newContext: OpenFeature.EvaluationContext) { onContextSetSemaphore?.wait() + activeContext = newContext } func initialize(initialContext: OpenFeature.EvaluationContext?) { + if let initialContext { + activeContext = initialContext + } } var hooks: [any OpenFeature.Hook] = [] From 1e5e42ed7b4adb6a5a117720b929904ec3b3bece Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Fri, 20 Dec 2024 14:25:34 +0100 Subject: [PATCH 4/9] refactor: Rename queue label Co-authored-by: Michael Beemer Signed-off-by: Fabrizio Demaria --- Sources/OpenFeature/OpenFeatureAPI.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/OpenFeature/OpenFeatureAPI.swift b/Sources/OpenFeature/OpenFeatureAPI.swift index 30c912e..03cf474 100644 --- a/Sources/OpenFeature/OpenFeatureAPI.swift +++ b/Sources/OpenFeature/OpenFeatureAPI.swift @@ -5,7 +5,7 @@ import Foundation /// Configuration here will be shared across all ``Client``s. public class OpenFeatureAPI { private let eventHandler = EventHandler() - private let queue = DispatchQueue(label: "com.providerDescriptor.queue") + private let queue = DispatchQueue(label: "com.openfeature.providerDescriptor.queue") private(set) var providerSubject = CurrentValueSubject(nil) private(set) var evaluationContext: EvaluationContext? From 613f27da6e0bab0cd94bc77e5c99ea5afb0a14d4 Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Fri, 20 Dec 2024 14:25:47 +0100 Subject: [PATCH 5/9] docs: Fix typo Co-authored-by: Michael Beemer Signed-off-by: Fabrizio Demaria --- Sources/OpenFeature/OpenFeatureAPI.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/OpenFeature/OpenFeatureAPI.swift b/Sources/OpenFeature/OpenFeatureAPI.swift index 03cf474..7835503 100644 --- a/Sources/OpenFeature/OpenFeatureAPI.swift +++ b/Sources/OpenFeature/OpenFeatureAPI.swift @@ -32,7 +32,7 @@ public class OpenFeatureAPI { /** Set provider and calls its `initialize`. - This async function returns when the `initalize` from the provider is completed. + This async function returns when the `initialize` from the provider is completed. */ public func setProviderAndWait(provider: FeatureProvider, initialContext: EvaluationContext?) async { await withCheckedContinuation { continuation in From 176accfa8dc3ffa583ce58794fb5d3d1b9deddb3 Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Fri, 20 Dec 2024 14:25:56 +0100 Subject: [PATCH 6/9] docs: Fix typo Co-authored-by: Michael Beemer Signed-off-by: Fabrizio Demaria --- Sources/OpenFeature/OpenFeatureAPI.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/OpenFeature/OpenFeatureAPI.swift b/Sources/OpenFeature/OpenFeatureAPI.swift index 7835503..55e274e 100644 --- a/Sources/OpenFeature/OpenFeatureAPI.swift +++ b/Sources/OpenFeature/OpenFeatureAPI.swift @@ -55,7 +55,7 @@ public class OpenFeatureAPI { /** Set provider and calls its `initialize`. - This async function returns when the `initalize` from the provider is completed. + This async function returns when the `initialize` from the provider is completed. */ public func setProviderAndWait(provider: FeatureProvider) async { await setProviderAndWait(provider: provider, initialContext: nil) From 2c7886b42bc99f8bd87e0fbdef31dea8b3bc1a7c Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Fri, 20 Dec 2024 14:27:55 +0100 Subject: [PATCH 7/9] refactor: Remove todos Signed-off-by: Fabrizio Demaria --- Sources/OpenFeature/OpenFeatureClient.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/OpenFeature/OpenFeatureClient.swift b/Sources/OpenFeature/OpenFeatureClient.swift index e957171..dab93a0 100644 --- a/Sources/OpenFeature/OpenFeatureClient.swift +++ b/Sources/OpenFeature/OpenFeatureClient.swift @@ -75,7 +75,7 @@ extension OpenFeatureClient { details.errorCode = .providerFatal details.errorMessage = OpenFeatureError .providerFatalError(message: "unknown") - .description // TODO Improve this message with error details + .description details.reason = Reason.error.rawValue return details case .notReady: @@ -90,7 +90,7 @@ extension OpenFeatureClient { details.errorCode = .general details.errorMessage = OpenFeatureError .generalError(message: "unknown") - .description // TODO Improve this message with error details + .description details.reason = Reason.error.rawValue return details case .ready: From b90d5931c1acbcef970448e4fe8a8fbe1ef282e5 Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Fri, 20 Dec 2024 14:36:04 +0100 Subject: [PATCH 8/9] refactor!: Rename afterAll with finally Signed-off-by: Fabrizio Demaria --- README.md | 2 +- Sources/OpenFeature/Hook.swift | 4 +-- Sources/OpenFeature/HookSupport.swift | 32 +++++++++---------- Sources/OpenFeature/OpenFeatureClient.swift | 2 +- .../DeveloperExperienceTests.swift | 24 +++++++------- .../Helpers/BooleanHookMock.swift | 8 ++--- .../Helpers/IntHookMock.swift | 8 ++--- Tests/OpenFeatureTests/HookSpecTests.swift | 12 +++---- Tests/OpenFeatureTests/HookSupportTests.swift | 8 ++--- 9 files changed, 50 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index b38cc67..0061ea0 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,7 @@ class BooleanHook: Hook { // do something } - func finallyAfter(ctx: HookContext, hints: [String: Any]) { + func finally(ctx: HookContext, hints: [String: Any]) { // do something } } diff --git a/Sources/OpenFeature/Hook.swift b/Sources/OpenFeature/Hook.swift index 72b9e35..2b037f6 100644 --- a/Sources/OpenFeature/Hook.swift +++ b/Sources/OpenFeature/Hook.swift @@ -11,7 +11,7 @@ public protocol Hook { func error(ctx: HookContext, error: Error, hints: [String: Any]) - func finallyAfter(ctx: HookContext, hints: [String: Any]) + func finally(ctx: HookContext, hints: [String: Any]) func supportsFlagValueType(flagValueType: FlagValueType) -> Bool } @@ -31,7 +31,7 @@ extension Hook { // Default implementation } - public func finallyAfter(ctx: HookContext, hints: [String: Any]) { + public func finally(ctx: HookContext, hints: [String: Any]) { // Default implementation } diff --git a/Sources/OpenFeature/HookSupport.swift b/Sources/OpenFeature/HookSupport.swift index 0034949..1f4375d 100644 --- a/Sources/OpenFeature/HookSupport.swift +++ b/Sources/OpenFeature/HookSupport.swift @@ -4,20 +4,12 @@ import os class HookSupport { var logger = Logger() - func errorHooks( - flagValueType: FlagValueType, hookCtx: HookContext, error: Error, hooks: [any Hook], hints: [String: Any] - ) { - hooks - .filter { $0.supportsFlagValueType(flagValueType: flagValueType) } - .forEach { $0.error(ctx: hookCtx, error: error, hints: hints) } - } - - func afterAllHooks( - flagValueType: FlagValueType, hookCtx: HookContext, hooks: [any Hook], hints: [String: Any] - ) { + func beforeHooks(flagValueType: FlagValueType, hookCtx: HookContext, hooks: [any Hook], hints: [String: Any]) + { hooks + .reversed() .filter { $0.supportsFlagValueType(flagValueType: flagValueType) } - .forEach { $0.finallyAfter(ctx: hookCtx, hints: hints) } + .forEach { $0.before(ctx: hookCtx, hints: hints) } } func afterHooks( @@ -32,11 +24,19 @@ class HookSupport { .forEach { $0.after(ctx: hookCtx, details: details, hints: hints) } } - func beforeHooks(flagValueType: FlagValueType, hookCtx: HookContext, hooks: [any Hook], hints: [String: Any]) - { + func errorHooks( + flagValueType: FlagValueType, hookCtx: HookContext, error: Error, hooks: [any Hook], hints: [String: Any] + ) { hooks - .reversed() .filter { $0.supportsFlagValueType(flagValueType: flagValueType) } - .forEach { $0.before(ctx: hookCtx, hints: hints) } + .forEach { $0.error(ctx: hookCtx, error: error, hints: hints) } + } + + func finallyHooks( + flagValueType: FlagValueType, hookCtx: HookContext, hooks: [any Hook], hints: [String: Any] + ) { + hooks + .filter { $0.supportsFlagValueType(flagValueType: flagValueType) } + .forEach { $0.finally(ctx: hookCtx, hints: hints) } } } diff --git a/Sources/OpenFeature/OpenFeatureClient.swift b/Sources/OpenFeature/OpenFeatureClient.swift index dab93a0..d54cdc6 100644 --- a/Sources/OpenFeature/OpenFeatureClient.swift +++ b/Sources/OpenFeature/OpenFeatureClient.swift @@ -143,7 +143,7 @@ extension OpenFeatureClient { hookSupport.errorHooks( flagValueType: T.flagValueType, hookCtx: hookCtx, error: error, hooks: mergedHooks, hints: hints) } - hookSupport.afterAllHooks( + hookSupport.finallyHooks( flagValueType: T.flagValueType, hookCtx: hookCtx, hooks: mergedHooks, hints: hints) return details } diff --git a/Tests/OpenFeatureTests/DeveloperExperienceTests.swift b/Tests/OpenFeatureTests/DeveloperExperienceTests.swift index 1056480..1bd1b28 100644 --- a/Tests/OpenFeatureTests/DeveloperExperienceTests.swift +++ b/Tests/OpenFeatureTests/DeveloperExperienceTests.swift @@ -131,16 +131,16 @@ final class DeveloperExperienceTests: XCTestCase { client.addHooks(booleanHook, intHook) _ = client.getValue(key: "string-test", defaultValue: "test") - XCTAssertEqual(booleanHook.finallyAfterCalled, 0) - XCTAssertEqual(intHook.finallyAfterCalled, 0) + XCTAssertEqual(booleanHook.finallyCalled, 0) + XCTAssertEqual(intHook.finallyCalled, 0) _ = client.getValue(key: "bool-test", defaultValue: false) - XCTAssertEqual(booleanHook.finallyAfterCalled, 1) - XCTAssertEqual(intHook.finallyAfterCalled, 0) + XCTAssertEqual(booleanHook.finallyCalled, 1) + XCTAssertEqual(intHook.finallyCalled, 0) _ = client.getValue(key: "int-test", defaultValue: 0) as Int64 - XCTAssertEqual(booleanHook.finallyAfterCalled, 1) - XCTAssertEqual(intHook.finallyAfterCalled, 1) + XCTAssertEqual(booleanHook.finallyCalled, 1) + XCTAssertEqual(intHook.finallyCalled, 1) } func testEvalHooks() async { @@ -152,16 +152,16 @@ final class DeveloperExperienceTests: XCTestCase { let options = FlagEvaluationOptions(hooks: [booleanHook, intHook]) _ = client.getValue(key: "test", defaultValue: "test", options: options) - XCTAssertEqual(booleanHook.finallyAfterCalled, 0) - XCTAssertEqual(intHook.finallyAfterCalled, 0) + XCTAssertEqual(booleanHook.finallyCalled, 0) + XCTAssertEqual(intHook.finallyCalled, 0) _ = client.getValue(key: "test", defaultValue: false, options: options) - XCTAssertEqual(booleanHook.finallyAfterCalled, 1) - XCTAssertEqual(intHook.finallyAfterCalled, 0) + XCTAssertEqual(booleanHook.finallyCalled, 1) + XCTAssertEqual(intHook.finallyCalled, 0) _ = client.getValue(key: "test", defaultValue: 0, options: options) as Int64 - XCTAssertEqual(booleanHook.finallyAfterCalled, 1) - XCTAssertEqual(intHook.finallyAfterCalled, 1) + XCTAssertEqual(booleanHook.finallyCalled, 1) + XCTAssertEqual(intHook.finallyCalled, 1) } func testBrokenProvider() async { diff --git a/Tests/OpenFeatureTests/Helpers/BooleanHookMock.swift b/Tests/OpenFeatureTests/Helpers/BooleanHookMock.swift index 13de2da..6cd4807 100644 --- a/Tests/OpenFeatureTests/Helpers/BooleanHookMock.swift +++ b/Tests/OpenFeatureTests/Helpers/BooleanHookMock.swift @@ -6,8 +6,8 @@ class BooleanHookMock: Hook { public var beforeCalled = 0 public var afterCalled = 0 - public var finallyAfterCalled = 0 public var errorCalled = 0 + public var finallyCalled = 0 private var prefix: String private var addEval: (String) -> Void @@ -38,8 +38,8 @@ class BooleanHookMock: Hook { self.addEval(self.prefix.isEmpty ? "error" : "\(self.prefix) error") } - func finallyAfter(ctx: HookContext, hints: [String: Any]) { - finallyAfterCalled += 1 - self.addEval(self.prefix.isEmpty ? "finallyAfter" : "\(self.prefix) finallyAfter") + func finally(ctx: HookContext, hints: [String: Any]) { + finallyCalled += 1 + self.addEval(self.prefix.isEmpty ? "finally" : "\(self.prefix) finally") } } diff --git a/Tests/OpenFeatureTests/Helpers/IntHookMock.swift b/Tests/OpenFeatureTests/Helpers/IntHookMock.swift index e64f76d..78d6623 100644 --- a/Tests/OpenFeatureTests/Helpers/IntHookMock.swift +++ b/Tests/OpenFeatureTests/Helpers/IntHookMock.swift @@ -6,7 +6,7 @@ class IntHookMock: Hook { public var beforeCalled = 0 public var afterCalled = 0 - public var finallyAfterCalled = 0 + public var finallyCalled = 0 public var errorCalled = 0 private var prefix: String @@ -38,8 +38,8 @@ class IntHookMock: Hook { self.addEval(self.prefix.isEmpty ? "error" : "\(self.prefix) error") } - func finallyAfter(ctx: HookContext, hints: [String: Any]) { - finallyAfterCalled += 1 - self.addEval(self.prefix.isEmpty ? "finallyAfter" : "\(self.prefix) finallyAfter") + func finally(ctx: HookContext, hints: [String: Any]) { + finallyCalled += 1 + self.addEval(self.prefix.isEmpty ? "finally" : "\(self.prefix) finally") } } diff --git a/Tests/OpenFeatureTests/HookSpecTests.swift b/Tests/OpenFeatureTests/HookSpecTests.swift index 66c1346..780c281 100644 --- a/Tests/OpenFeatureTests/HookSpecTests.swift +++ b/Tests/OpenFeatureTests/HookSpecTests.swift @@ -30,7 +30,7 @@ final class HookSpecTests: XCTestCase { XCTAssertEqual(hook.beforeCalled, 1) XCTAssertEqual(hook.afterCalled, 1) XCTAssertEqual(hook.errorCalled, 0) - XCTAssertEqual(hook.finallyAfterCalled, 1) + XCTAssertEqual(hook.finallyCalled, 1) XCTAssertNotNil(eventState) } @@ -62,7 +62,7 @@ final class HookSpecTests: XCTestCase { XCTAssertEqual(hook.beforeCalled, 1) XCTAssertEqual(hook.afterCalled, 0) XCTAssertEqual(hook.errorCalled, 1) - XCTAssertEqual(hook.finallyAfterCalled, 1) + XCTAssertEqual(hook.finallyCalled, 1) XCTAssertNotNil(eventState) } @@ -107,10 +107,10 @@ final class HookSpecTests: XCTestCase { "invocation after", "client after", "api after", - "provider finallyAfter", - "invocation finallyAfter", - "client finallyAfter", - "api finallyAfter", + "provider finally", + "invocation finally", + "client finally", + "api finally", ]) XCTAssertNotNil(eventState) } diff --git a/Tests/OpenFeatureTests/HookSupportTests.swift b/Tests/OpenFeatureTests/HookSupportTests.swift index a3d14b8..145fcf7 100644 --- a/Tests/OpenFeatureTests/HookSupportTests.swift +++ b/Tests/OpenFeatureTests/HookSupportTests.swift @@ -28,21 +28,21 @@ final class HookSupportTests: XCTestCase { details: FlagEvaluationDetails(flagKey: "", value: false), hooks: [hook], hints: [:]) - hookSupport.afterAllHooks( + hookSupport.errorHooks( flagValueType: .boolean, hookCtx: hookContext, + error: OpenFeatureError.invalidContextError, hooks: [hook], hints: [:]) - hookSupport.errorHooks( + hookSupport.finallyHooks( flagValueType: .boolean, hookCtx: hookContext, - error: OpenFeatureError.invalidContextError, hooks: [hook], hints: [:]) XCTAssertEqual(hook.beforeCalled, 1) XCTAssertEqual(hook.afterCalled, 1) - XCTAssertEqual(hook.finallyAfterCalled, 1) XCTAssertEqual(hook.errorCalled, 1) + XCTAssertEqual(hook.finallyCalled, 1) } } From 81c9fa27c9091cca3c32211e627b3811a3e5e059 Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Fri, 20 Dec 2024 14:40:44 +0100 Subject: [PATCH 9/9] refactor!: Add eval details to finally hook Signed-off-by: Fabrizio Demaria --- Sources/OpenFeature/Hook.swift | 4 ++-- Sources/OpenFeature/HookSupport.swift | 8 ++++++-- Sources/OpenFeature/OpenFeatureClient.swift | 2 +- Tests/OpenFeatureTests/Helpers/BooleanHookMock.swift | 2 +- Tests/OpenFeatureTests/Helpers/IntHookMock.swift | 2 +- Tests/OpenFeatureTests/HookSupportTests.swift | 1 + 6 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Sources/OpenFeature/Hook.swift b/Sources/OpenFeature/Hook.swift index 2b037f6..fcf4bff 100644 --- a/Sources/OpenFeature/Hook.swift +++ b/Sources/OpenFeature/Hook.swift @@ -11,7 +11,7 @@ public protocol Hook { func error(ctx: HookContext, error: Error, hints: [String: Any]) - func finally(ctx: HookContext, hints: [String: Any]) + func finally(ctx: HookContext, details: FlagEvaluationDetails, hints: [String: Any]) func supportsFlagValueType(flagValueType: FlagValueType) -> Bool } @@ -31,7 +31,7 @@ extension Hook { // Default implementation } - public func finally(ctx: HookContext, hints: [String: Any]) { + public func finally(ctx: HookContext, details: FlagEvaluationDetails, hints: [String: Any]) { // Default implementation } diff --git a/Sources/OpenFeature/HookSupport.swift b/Sources/OpenFeature/HookSupport.swift index 1f4375d..acf9455 100644 --- a/Sources/OpenFeature/HookSupport.swift +++ b/Sources/OpenFeature/HookSupport.swift @@ -33,10 +33,14 @@ class HookSupport { } func finallyHooks( - flagValueType: FlagValueType, hookCtx: HookContext, hooks: [any Hook], hints: [String: Any] + flagValueType: FlagValueType, + hookCtx: HookContext, + details: FlagEvaluationDetails, + hooks: [any Hook], + hints: [String: Any] ) { hooks .filter { $0.supportsFlagValueType(flagValueType: flagValueType) } - .forEach { $0.finally(ctx: hookCtx, hints: hints) } + .forEach { $0.finally(ctx: hookCtx, details: details, hints: hints) } } } diff --git a/Sources/OpenFeature/OpenFeatureClient.swift b/Sources/OpenFeature/OpenFeatureClient.swift index d54cdc6..30396e3 100644 --- a/Sources/OpenFeature/OpenFeatureClient.swift +++ b/Sources/OpenFeature/OpenFeatureClient.swift @@ -144,7 +144,7 @@ extension OpenFeatureClient { flagValueType: T.flagValueType, hookCtx: hookCtx, error: error, hooks: mergedHooks, hints: hints) } hookSupport.finallyHooks( - flagValueType: T.flagValueType, hookCtx: hookCtx, hooks: mergedHooks, hints: hints) + flagValueType: T.flagValueType, hookCtx: hookCtx, details: details, hooks: mergedHooks, hints: hints) return details } diff --git a/Tests/OpenFeatureTests/Helpers/BooleanHookMock.swift b/Tests/OpenFeatureTests/Helpers/BooleanHookMock.swift index 6cd4807..dbccca7 100644 --- a/Tests/OpenFeatureTests/Helpers/BooleanHookMock.swift +++ b/Tests/OpenFeatureTests/Helpers/BooleanHookMock.swift @@ -38,7 +38,7 @@ class BooleanHookMock: Hook { self.addEval(self.prefix.isEmpty ? "error" : "\(self.prefix) error") } - func finally(ctx: HookContext, hints: [String: Any]) { + func finally(ctx: HookContext, details: FlagEvaluationDetails, hints: [String: Any]) { finallyCalled += 1 self.addEval(self.prefix.isEmpty ? "finally" : "\(self.prefix) finally") } diff --git a/Tests/OpenFeatureTests/Helpers/IntHookMock.swift b/Tests/OpenFeatureTests/Helpers/IntHookMock.swift index 78d6623..0a5f050 100644 --- a/Tests/OpenFeatureTests/Helpers/IntHookMock.swift +++ b/Tests/OpenFeatureTests/Helpers/IntHookMock.swift @@ -38,7 +38,7 @@ class IntHookMock: Hook { self.addEval(self.prefix.isEmpty ? "error" : "\(self.prefix) error") } - func finally(ctx: HookContext, hints: [String: Any]) { + func finally(ctx: HookContext, details: FlagEvaluationDetails, hints: [String: Any]) { finallyCalled += 1 self.addEval(self.prefix.isEmpty ? "finally" : "\(self.prefix) finally") } diff --git a/Tests/OpenFeatureTests/HookSupportTests.swift b/Tests/OpenFeatureTests/HookSupportTests.swift index 145fcf7..78a9b52 100644 --- a/Tests/OpenFeatureTests/HookSupportTests.swift +++ b/Tests/OpenFeatureTests/HookSupportTests.swift @@ -37,6 +37,7 @@ final class HookSupportTests: XCTestCase { hookSupport.finallyHooks( flagValueType: .boolean, hookCtx: hookContext, + details: FlagEvaluationDetails(flagKey: "", value: false), hooks: [hook], hints: [:])