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

Commit

Permalink
Merge pull request #22 from spotify/provider-events
Browse files Browse the repository at this point in the history
Provider events
  • Loading branch information
Calibretto authored Aug 2, 2023
2 parents b606a1e + dad29e4 commit 42832f4
Show file tree
Hide file tree
Showing 15 changed files with 294 additions and 42 deletions.
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,
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)
}
}
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

0 comments on commit 42832f4

Please sign in to comment.