Skip to content

Commit

Permalink
feat!: Align ProviderState and ProviderEvent with Spec
Browse files Browse the repository at this point in the history
Signed-off-by: Fabrizio Demaria <[email protected]>
  • Loading branch information
fabriziodemaria committed Dec 18, 2024
1 parent b900743 commit ac15098
Show file tree
Hide file tree
Showing 17 changed files with 391 additions and 253 deletions.
17 changes: 6 additions & 11 deletions Sources/OpenFeature/EventHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,24 @@ import Combine
import Foundation

public class EventHandler: EventSender, EventPublisher {
private let eventState: CurrentValueSubject<ProviderEvent, Never>
private let lastSentEvent = PassthroughSubject<ProviderEvent?, Never>()

convenience init() {
self.init(.notReady)
public init() {
}

public init(_ state: ProviderEvent) {
eventState = CurrentValueSubject<ProviderEvent, Never>(state)
}

public func observe() -> AnyPublisher<ProviderEvent, Never> {
return eventState.eraseToAnyPublisher()
public func observe() -> AnyPublisher<ProviderEvent?, Never> {
return lastSentEvent.eraseToAnyPublisher()
}

public func send(
_ event: ProviderEvent
) {
eventState.send(event)
lastSentEvent.send(event)
}
}

public protocol EventPublisher {
func observe() -> AnyPublisher<ProviderEvent, Never>
func observe() -> AnyPublisher<ProviderEvent?, Never>
}

public protocol EventSender {
Expand Down
158 changes: 113 additions & 45 deletions Sources/OpenFeature/OpenFeatureAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,52 +4,90 @@ 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<FeatureProvider?, Never>(nil)
private(set) var evaluationContext: EvaluationContext?
private(set) var providerStatus: ProviderStatus = .notReady
private(set) var hooks: [any Hook] = []
private var providerSubject = CurrentValueSubject<FeatureProvider?, Never>(nil)

/// The ``OpenFeatureAPI`` singleton
static public let shared = 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? {
Expand All @@ -72,43 +110,73 @@ public class OpenFeatureAPI {
self.hooks.removeAll()
}

public func observe() -> AnyPublisher<ProviderEvent, Never> {
// TODO OpenFeatureAPI should listen for Provider's events and change state accordingly
public func observe() -> AnyPublisher<ProviderEvent?, Never> {
return providerSubject.map { provider in
if let provider = provider {
return provider.observe()
.merge(with: self.eventHandler.observe())
.eraseToAnyPublisher()
} else {
return Empty<ProviderEvent, Never>()
return Empty<ProviderEvent?, Never>()
.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
}
}
59 changes: 42 additions & 17 deletions Sources/OpenFeature/OpenFeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,58 +68,83 @@ extension OpenFeatureClient {
defaultValue: T,
options: FlagEvaluationOptions?
) -> FlagEvaluationDetails<T> {
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<T: AllowedFlagValueType>(
key: String,
defaultValue: T,
options: FlagEvaluationOptions?,
openFeatureApiState: OpenFeatureAPI.OpenFeatureState
) -> FlagEvaluationDetails<T> {
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,
defaultValue: defaultValue,
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<T>.from(providerEval: providerEval, flagKey: key)
details = evalDetails

details = FlagEvaluationDetails<T>.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
}

Expand Down
8 changes: 6 additions & 2 deletions Sources/OpenFeature/Provider/FeatureProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down
4 changes: 1 addition & 3 deletions Sources/OpenFeature/Provider/NoOpProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -67,7 +65,7 @@ class NoOpProvider: FeatureProvider {
value: defaultValue, variant: NoOpProvider.passedInDefault, reason: Reason.defaultReason.rawValue)
}

func observe() -> AnyPublisher<ProviderEvent, Never> {
func observe() -> AnyPublisher<ProviderEvent?, Never> {
return eventHandler.observe()
}
}
Expand Down
Loading

0 comments on commit ac15098

Please sign in to comment.