Skip to content
This repository has been archived by the owner on Sep 13, 2023. It is now read-only.

Provider events #22

Merged
merged 7 commits into from
Aug 2, 2023
Merged
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
11 changes: 11 additions & 0 deletions Sources/OpenFeature/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,15 @@ public protocol Client: Features {
/// Hooks are run in the order they're added in the before stage. They are run in reverse order for all
/// other stages.
func addHooks(_ hooks: any Hook...)

/// Add a handler for a particular provider event
/// - Parameter observer: The object observing the event.
/// - Parameter selector: The selector to call for this event.
/// - Parameter event: The event to listen for.
func addHandler(observer: Any, selector: Selector, event: ProviderEvent)

/// Remove a handler for a particular provider event
/// - Parameter observer: The object observing the event.
/// - Parameter event: The event being listened to.
func removeHandler(observer: Any, event: ProviderEvent)
}
53 changes: 47 additions & 6 deletions Sources/OpenFeature/OpenFeatureAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,25 @@ public class OpenFeatureAPI {
private var _context: EvaluationContext?
private(set) var hooks: [any Hook] = []

private let providerNotificationCentre = NotificationCenter()

/// The ``OpenFeatureAPI`` singleton
static public let shared = OpenFeatureAPI()

public init() {
}

public func setProvider(provider: FeatureProvider) async {
await self.setProvider(provider: provider, initialContext: nil)
public func setProvider(provider: FeatureProvider) {
self.setProvider(provider: provider, initialContext: nil)
}

public func setProvider(provider: FeatureProvider, initialContext: EvaluationContext?) async {
public func setProvider(provider: FeatureProvider, initialContext: EvaluationContext?) {
self._provider = provider
if let context = initialContext {
self._context = context
}
await provider.initialize(initialContext: self._context)

provider.initialize(initialContext: self._context)
}

public func getProvider() -> FeatureProvider? {
Expand All @@ -33,9 +36,10 @@ public class OpenFeatureAPI {
self._provider = nil
}

public func setEvaluationContext(evaluationContext: EvaluationContext) async {
public func setEvaluationContext(evaluationContext: EvaluationContext) {
let oldContext = self._context
self._context = evaluationContext
await getProvider()?.onContextSet(oldContext: self._context, newContext: evaluationContext)
getProvider()?.onContextSet(oldContext: oldContext, newContext: evaluationContext)
}

public func getEvaluationContext() -> EvaluationContext? {
Expand All @@ -62,3 +66,40 @@ public class OpenFeatureAPI {
self.hooks.removeAll()
}
}

// MARK: Provider Events

extension OpenFeatureAPI {
public func addHandler(observer: Any, selector: Selector, event: ProviderEvent) {
providerNotificationCentre.addObserver(
observer,
selector: selector,
name: event.notification,
object: nil
)
}

public func removeHandler(observer: Any, event: ProviderEvent) {
providerNotificationCentre.removeObserver(observer, name: event.notification, object: nil)
}

public func emitEvent(
_ event: ProviderEvent,
provider: FeatureProvider,
error: Error? = nil,
Calibretto marked this conversation as resolved.
Show resolved Hide resolved
details: [AnyHashable: Any]? = nil
) {
var userInfo: [AnyHashable: Any] = [:]
userInfo[providerEventDetailsKeyProvider] = provider

if let error {
userInfo[providerEventDetailsKeyError] = error
}

if let details {
userInfo.merge(details) { $1 } // Merge, keeping value from `details` if any conflicts
}

providerNotificationCentre.post(name: event.notification, object: nil, userInfo: userInfo)
Calibretto marked this conversation as resolved.
Show resolved Hide resolved
}
}
43 changes: 43 additions & 0 deletions Sources/OpenFeature/OpenFeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ public class OpenFeatureClient: Client {
private var hookSupport = HookSupport()
private var logger = Logger()

private let providerNotificationCentre = NotificationCenter()

public init(openFeatureApi: OpenFeatureAPI, name: String?, version: String?) {
self.openFeatureApi = openFeatureApi
self.name = name
self.version = version
self.metadata = Metadata(name: name)

subscribeToAllProviderEvents()
}

public func addHooks(_ hooks: any Hook...) {
Expand Down Expand Up @@ -196,3 +200,42 @@ extension OpenFeatureClient {
throw OpenFeatureError.generalError(message: "Unable to match default value type with flag value type")
}
}

// MARK: Events

extension OpenFeatureClient {
public func subscribeToAllProviderEvents() {
ProviderEvent.allCases.forEach { event in
OpenFeatureAPI.shared.addHandler(
observer: self,
selector: #selector(handleProviderEvent(notification:)),
event: event)
}
}

public func unsubscribeFromAllProviderEvents() {
ProviderEvent.allCases.forEach { event in
OpenFeatureAPI.shared.removeHandler(observer: self, event: event)
}
}

@objc public func handleProviderEvent(notification: Notification) {
var userInfo: [AnyHashable: Any] = notification.userInfo ?? [:]
userInfo[providerEventDetailsKeyClient] = self

providerNotificationCentre.post(name: notification.name, object: nil, userInfo: userInfo)
}

public func addHandler(observer: Any, selector: Selector, event: ProviderEvent) {
providerNotificationCentre.addObserver(
observer,
selector: selector,
name: event.notification,
object: nil
)
}

public func removeHandler(observer: Any, event: ProviderEvent) {
providerNotificationCentre.removeObserver(observer, name: event.notification, object: nil)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ public protocol FeatureProvider {
var metadata: ProviderMetadata { get }

/// Called by OpenFeatureAPI whenever the new Provider is registered
func initialize(initialContext: EvaluationContext?) async
func initialize(initialContext: EvaluationContext?)

/// Called by OpenFeatureAPI whenever a new EvaluationContext is set by the application
func onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) async
func onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext)

func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws
-> ProviderEvaluation<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@ import Foundation
class NoOpProvider: FeatureProvider {
public static let passedInDefault = "Passed in default"

public enum Mode {
case normal
case error(message: String)
}

var metadata: ProviderMetadata = NoOpMetadata(name: "No-op provider")
var hooks: [any Hook] = []

func onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) {
// no-op
OpenFeatureAPI.shared.emitEvent(.configurationChanged, provider: self)
}

func initialize(initialContext: EvaluationContext?) {
// no-op
OpenFeatureAPI.shared.emitEvent(.ready, provider: self)
}

func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws
Expand Down
16 changes: 16 additions & 0 deletions Sources/OpenFeature/Provider/ProviderEvents.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Foundation

public let providerEventDetailsKeyProvider = "Provider"
public let providerEventDetailsKeyClient = "Client"
public let providerEventDetailsKeyError = "Error"

public enum ProviderEvent: String, CaseIterable {
case ready = "PROVIDER_READY"
case error = "PROVIDER_ERROR"
case configurationChanged = "PROVIDER_CONFIGURATION_CHANGED"
case stale = "PROVIDER_STALE"

var notification: NSNotification.Name {
NSNotification.Name(rawValue)
}
}
16 changes: 8 additions & 8 deletions Tests/OpenFeatureTests/DeveloperExperienceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@ final class DeveloperExperienceTests: XCTestCase {
XCTAssertEqual(flagValue, "no-op")
}

func testSimpleBooleanFlag() async {
await OpenFeatureAPI.shared.setProvider(provider: NoOpProvider())
func testSimpleBooleanFlag() {
OpenFeatureAPI.shared.setProvider(provider: NoOpProvider())
let client = OpenFeatureAPI.shared.getClient()

let flagValue = client.getValue(key: "test", defaultValue: false)
XCTAssertFalse(flagValue)
}

func testClientHooks() async {
await OpenFeatureAPI.shared.setProvider(provider: NoOpProvider())
func testClientHooks() {
OpenFeatureAPI.shared.setProvider(provider: NoOpProvider())
let client = OpenFeatureAPI.shared.getClient()

let booleanHook = BooleanHookMock()
Expand All @@ -40,8 +40,8 @@ final class DeveloperExperienceTests: XCTestCase {
XCTAssertEqual(intHook.finallyAfterCalled, 1)
}

func testEvalHooks() async {
await OpenFeatureAPI.shared.setProvider(provider: NoOpProvider())
func testEvalHooks() {
OpenFeatureAPI.shared.setProvider(provider: NoOpProvider())
let client = OpenFeatureAPI.shared.getClient()

let booleanHook = BooleanHookMock()
Expand All @@ -61,8 +61,8 @@ final class DeveloperExperienceTests: XCTestCase {
XCTAssertEqual(intHook.finallyAfterCalled, 1)
}

func testBrokenProvider() async {
await OpenFeatureAPI.shared.setProvider(provider: AlwaysBrokenProvider())
func testBrokenProvider() {
OpenFeatureAPI.shared.setProvider(provider: AlwaysBrokenProvider())
let client = OpenFeatureAPI.shared.getClient()

let details = client.getDetails(key: "test", defaultValue: false)
Expand Down
53 changes: 43 additions & 10 deletions Tests/OpenFeatureTests/FlagEvaluationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,31 @@ import XCTest
@testable import OpenFeature

final class FlagEvaluationTests: XCTestCase {
override func setUp() {
super.setUp()

OpenFeatureAPI.shared.addHandler(
observer: self, selector: #selector(readyEventEmitted(notification:)), event: .ready
)

OpenFeatureAPI.shared.addHandler(
observer: self, selector: #selector(errorEventEmitted(notification:)), event: .error
)
}

func testSingletonPersists() {
XCTAssertTrue(OpenFeatureAPI.shared === OpenFeatureAPI.shared)
}

func testApiSetsProvider() async {
func testApiSetsProvider() {
let provider = NoOpProvider()
await OpenFeatureAPI.shared.setProvider(provider: provider)
OpenFeatureAPI.shared.setProvider(provider: provider)

XCTAssertTrue((OpenFeatureAPI.shared.getProvider() as? NoOpProvider) === provider)
}

func testProviderMetadata() async {
await OpenFeatureAPI.shared.setProvider(provider: DoSomethingProvider())
func testProviderMetadata() {
OpenFeatureAPI.shared.setProvider(provider: DoSomethingProvider())

XCTAssertEqual(OpenFeatureAPI.shared.getProviderMetadata()?.name, DoSomethingProvider.name)
}
Expand Down Expand Up @@ -51,8 +63,10 @@ final class FlagEvaluationTests: XCTestCase {
XCTAssertEqual(client.hooks.count, 2)
}

func testSimpleFlagEvaluation() async {
await OpenFeatureAPI.shared.setProvider(provider: DoSomethingProvider())
func testSimpleFlagEvaluation() {
OpenFeatureAPI.shared.setProvider(provider: DoSomethingProvider())
wait(for: [readyExpectation], timeout: 5)

let client = OpenFeatureAPI.shared.getClient()
let key = "key"

Expand Down Expand Up @@ -89,7 +103,9 @@ final class FlagEvaluationTests: XCTestCase {
}

func testDetailedFlagEvaluation() async {
await OpenFeatureAPI.shared.setProvider(provider: DoSomethingProvider())
OpenFeatureAPI.shared.setProvider(provider: DoSomethingProvider())
wait(for: [readyExpectation], timeout: 5)

let client = OpenFeatureAPI.shared.getClient()
let key = "key"

Expand Down Expand Up @@ -132,7 +148,9 @@ final class FlagEvaluationTests: XCTestCase {
}

func testHooksAreFired() async {
await OpenFeatureAPI.shared.setProvider(provider: NoOpProvider())
OpenFeatureAPI.shared.setProvider(provider: NoOpProvider())
wait(for: [readyExpectation], timeout: 5)

let client = OpenFeatureAPI.shared.getClient()

let clientHook = BooleanHookMock()
Expand All @@ -148,8 +166,10 @@ final class FlagEvaluationTests: XCTestCase {
XCTAssertEqual(invocationHook.beforeCalled, 1)
}

func testBrokenProvider() async {
await OpenFeatureAPI.shared.setProvider(provider: AlwaysBrokenProvider())
func testBrokenProvider() {
OpenFeatureAPI.shared.setProvider(provider: AlwaysBrokenProvider())
wait(for: [errorExpectation], timeout: 5)

let client = OpenFeatureAPI.shared.getClient()

XCTAssertFalse(client.getValue(key: "testkey", defaultValue: false))
Expand All @@ -167,4 +187,17 @@ final class FlagEvaluationTests: XCTestCase {
let client = OpenFeatureAPI.shared.getClient(name: "test", version: nil)
XCTAssertEqual(client.metadata.name, "test")
}

// MARK: Event Handlers
let readyExpectation = XCTestExpectation(description: "Ready")

func readyEventEmitted(notification: NSNotification) {
readyExpectation.fulfill()
}

let errorExpectation = XCTestExpectation(description: "Error")

func errorEventEmitted(notification: NSNotification) {
errorExpectation.fulfill()
}
}
6 changes: 4 additions & 2 deletions Tests/OpenFeatureTests/Helpers/AlwaysBrokenProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ class AlwaysBrokenProvider: FeatureProvider {
var hooks: [any Hook] = []

func onContextSet(oldContext: OpenFeature.EvaluationContext?, newContext: OpenFeature.EvaluationContext) {
// no-op
let error = OpenFeatureError.generalError(message: "Always Fails")
OpenFeatureAPI.shared.emitEvent(.error, provider: self, error: error)
}

func initialize(initialContext: OpenFeature.EvaluationContext?) {
// no-op
let error = OpenFeatureError.generalError(message: "Always Fails")
OpenFeatureAPI.shared.emitEvent(.error, provider: self, error: error)
}

func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws
Expand Down
4 changes: 2 additions & 2 deletions Tests/OpenFeatureTests/Helpers/DoSomethingProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ class DoSomethingProvider: FeatureProvider {
public static let name = "Something"

func onContextSet(oldContext: OpenFeature.EvaluationContext?, newContext: OpenFeature.EvaluationContext) {
// no-op
OpenFeatureAPI.shared.emitEvent(.configurationChanged, provider: self)
}

func initialize(initialContext: OpenFeature.EvaluationContext?) {
// no-op
OpenFeatureAPI.shared.emitEvent(.ready, provider: self)
}

var hooks: [any OpenFeature.Hook] = []
Expand Down
Loading