Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: Spec v0.8 adherence #46

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<p align="center" class="github-badges">
<!-- TODO: update this with the version of the SDK your implementation supports -->

<a href="https://github.com/open-feature/spec/releases/tag/v0.7.0">
<a href="https://github.com/open-feature/spec/releases/tag/v0.8.0">
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.7.0&color=yellow&style=for-the-badge" />
</a>
<!-- x-release-please-start-version -->
Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -242,7 +247,7 @@ class BooleanHook: Hook {
// do something
}

func finallyAfter<HookValue>(ctx: HookContext<HookValue>, hints: [String: Any]) {
func finally<HookValue>(ctx: HookContext<HookValue>, hints: [String: Any]) {
// do something
}
}
Expand Down
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
4 changes: 2 additions & 2 deletions Sources/OpenFeature/Hook.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public protocol Hook {

func error<HookValue>(ctx: HookContext<HookValue>, error: Error, hints: [String: Any])

func finallyAfter<HookValue>(ctx: HookContext<HookValue>, hints: [String: Any])
func finally<HookValue>(ctx: HookContext<HookValue>, details: FlagEvaluationDetails<HookValue>, hints: [String: Any])

func supportsFlagValueType(flagValueType: FlagValueType) -> Bool
}
Expand All @@ -31,7 +31,7 @@ extension Hook {
// Default implementation
}

public func finallyAfter<HookValue>(ctx: HookContext<HookValue>, hints: [String: Any]) {
public func finally<HookValue>(ctx: HookContext<HookValue>, details: FlagEvaluationDetails<HookValue>, hints: [String: Any]) {
// Default implementation
}

Expand Down
36 changes: 20 additions & 16 deletions Sources/OpenFeature/HookSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,12 @@ import os
class HookSupport {
var logger = Logger()

func errorHooks<T>(
flagValueType: FlagValueType, hookCtx: HookContext<T>, 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<T>(
flagValueType: FlagValueType, hookCtx: HookContext<T>, hooks: [any Hook], hints: [String: Any]
) {
func beforeHooks<T>(flagValueType: FlagValueType, hookCtx: HookContext<T>, 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<T>(
Expand All @@ -32,11 +24,23 @@ class HookSupport {
.forEach { $0.after(ctx: hookCtx, details: details, hints: hints) }
}

func beforeHooks<T>(flagValueType: FlagValueType, hookCtx: HookContext<T>, hooks: [any Hook], hints: [String: Any])
{
func errorHooks<T>(
flagValueType: FlagValueType, hookCtx: HookContext<T>, 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<T>(
flagValueType: FlagValueType,
hookCtx: HookContext<T>,
details: FlagEvaluationDetails<T>,
hooks: [any Hook],
hints: [String: Any]
) {
hooks
.filter { $0.supportsFlagValueType(flagValueType: flagValueType) }
.forEach { $0.finally(ctx: hookCtx, details: details, hints: hints) }
}
}
174 changes: 129 additions & 45 deletions Sources/OpenFeature/OpenFeatureAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,52 +4,107 @@ 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.openfeature.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)
}
}
Comment on lines +21 to +30
Copy link
Contributor Author

@fabriziodemaria fabriziodemaria Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now the OpenFeature SDK only offers two APIs to set a Provider, and both require await from the Application: await for an async function (i.e. setProviderAndAwait) or await for a .ready event.

If a Provider offers local-resolve or cached flags data, it would be useful to offer a non-await (i.e. synchronous) function to set the Provider and have it "ready" right away: synchronous functions are easier to integrate in the Application (especially at startup, when latency is critical).

One possibility could be to make setProvider blocking until initialize (which is called inside "setProviderInternal") is finished:

    public func setProvider(provider: FeatureProvider, initialContext: EvaluationContext?) {
        let semaphore = DispatchSemaphore(value: 0)
        let _ = queue.sync {
            Task {
                await self.setProviderInternal(provider: provider, initialContext: initialContext)
                semaphore.signal()
            }
        }
        semaphore.wait()
    }

This could also be added later as a new function, e.g. setProviderSync

Copy link
Contributor Author

@fabriziodemaria fabriziodemaria Jan 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found the document in the OFEP repo where it was decided to add the helper function setProviderAndWait (link). Maybe we could create a new proposal for yet a new function that better supports the fully-synchronous initialization use-case reported above. I don't think we need to wait for that in order to merge this PR though

}

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 `initialize` 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 `initialize` 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
}
}

/**
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) {
let oldContext = self._context
self._context = evaluationContext
getProvider()?.onContextSet(oldContext: oldContext, newContext: evaluationContext)
queue.async {
Task {
await self.updateContext(evaluationContext: evaluationContext)
}
}
}

/**
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._context
return self.evaluationContext
}

public func getProviderStatus() -> ProviderStatus {
return self.providerStatus
}

public func getProviderMetadata() -> ProviderMetadata? {
Expand All @@ -72,43 +127,72 @@ public class OpenFeatureAPI {
self.hooks.removeAll()
}

public func observe() -> AnyPublisher<ProviderEvent, Never> {
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
}
}
Loading
Loading