Skip to content
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
85 changes: 84 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ Task {
| ✅ | [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. |
| | [MultiProvider](#multiprovider) | Utilize multiple providers in a single application. |
| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. |
| ❌ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
Expand Down Expand Up @@ -184,6 +184,89 @@ Logging customization is not yet available in the iOS SDK.

Support for named clients is not yet available in the iOS SDK.

### MultiProvider

The `MultiProvider` allows you to combine multiple feature flag providers into a single provider, enabling you to use different providers for different flags or implement fallback mechanisms. This is useful when migrating between providers, implementing A/B testing across providers, or ensuring high availability.

#### Basic Usage

```swift
import OpenFeature

Task {
// Create individual providers
let primaryProvider = PrimaryProvider()
let fallbackProvider = FallbackProvider()

// Create a MultiProvider with default FirstMatchStrategy
let multiProvider = MultiProvider(providers: [primaryProvider, fallbackProvider])

// Set the MultiProvider as the global provider
await OpenFeatureAPI.shared.setProviderAndWait(provider: multiProvider)

// Use flags normally - the MultiProvider will handle provider selection
let client = OpenFeatureAPI.shared.getClient()
let flagValue = client.getBooleanValue(key: "my-flag", defaultValue: false)
}
```

#### Evaluation Strategies

The `MultiProvider` supports different strategies for evaluating flags across multiple providers:

##### FirstMatchStrategy (Default)

The `FirstMatchStrategy` evaluates providers in order and returns the first result that doesn't indicate "flag not found". If a provider returns an error other than "flag not found", that error is returned immediately.

```swift
let multiProvider = MultiProvider(
providers: [primaryProvider, fallbackProvider],
strategy: FirstMatchStrategy()
)
```

##### FirstSuccessfulStrategy

The `FirstSuccessfulStrategy` evaluates providers in order and returns the first successful result (no error). Unlike `FirstMatchStrategy`, it continues to the next provider if any error occurs, including "flag not found".

```swift
let multiProvider = MultiProvider(
providers: [primaryProvider, fallbackProvider],
strategy: FirstSuccessfulStrategy()
)
```

#### Use Cases

**Provider Migration:**
```swift
// Gradually migrate from OldProvider to NewProvider
let multiProvider = MultiProvider(providers: [
NewProvider(), // Check new provider first
OldProvider() // Fall back to old provider
])
```

**High Availability:**
```swift
// Use multiple providers for redundancy
let multiProvider = MultiProvider(providers: [
RemoteProvider(),
LocalCacheProvider(),
StaticProvider()
])
```

**Environment-Specific Providers:**
```swift
// Different providers for different environments
let providers = [
EnvironmentProvider(environment: "production"),
DefaultProvider()
]
let multiProvider = MultiProvider(providers: providers)
```

### Eventing

Events allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/// FirstMatchStrategy is a strategy that evaluates a feature flag across multiple providers
/// and returns the first result. Skips providers that indicate they had no value due to flag not found.
/// If any provider returns an error result other than flag not found, the error is returned.
final public class FirstMatchStrategy: Strategy {
public init() {}

public func evaluate<T>(
providers: [FeatureProvider],
key: String,
defaultValue: T,
evaluationContext: EvaluationContext?,
flagEvaluation: FlagEvaluation<T>
) throws -> ProviderEvaluation<T> where T: AllowedFlagValueType {
for provider in providers {
do {
let eval = try flagEvaluation(provider)(key, defaultValue, evaluationContext)
if eval.errorCode != ErrorCode.flagNotFound {
return eval
}
} catch OpenFeatureError.flagNotFoundError {
continue
} catch let error as OpenFeatureError {
return ProviderEvaluation(
value: defaultValue,
reason: Reason.error.rawValue,
errorCode: error.errorCode(),
errorMessage: error.description
)
} catch {
throw error
}
}

return ProviderEvaluation(
value: defaultValue,
reason: Reason.defaultReason.rawValue,
errorCode: ErrorCode.flagNotFound
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/// FirstSuccessfulStrategy is a strategy that evaluates a feature flag across multiple providers
/// and returns the first result. Similar to `FirstMatchStrategy` but does not bubble up individual provider errors.
/// If no provider successfully responds, it will return an error.
final public class FirstSuccessfulStrategy: Strategy {
public func evaluate<T>(
providers: [FeatureProvider],
key: String,
defaultValue: T,
evaluationContext: EvaluationContext?,
flagEvaluation: FlagEvaluation<T>
) throws -> ProviderEvaluation<T> where T: AllowedFlagValueType {
var flagNotFound = false
for provider in providers {
do {
let eval = try flagEvaluation(provider)(key, defaultValue, evaluationContext)
if eval.errorCode == nil {
return eval
} else if eval.errorCode == ErrorCode.flagNotFound {
flagNotFound = true
}
} catch OpenFeatureError.flagNotFoundError {
flagNotFound = true
} catch {
continue
}
}

let errorCode = flagNotFound ? ErrorCode.flagNotFound : ErrorCode.general
return ProviderEvaluation(
value: defaultValue,
reason: Reason.defaultReason.rawValue,
errorCode: errorCode
)
}
}
130 changes: 130 additions & 0 deletions Sources/OpenFeature/Provider/MultiProvider/MultiProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import Combine
import Foundation

/// A provider that combines multiple providers into a single provider.
public class MultiProvider: FeatureProvider {
public var hooks: [any Hook] {
[]
}

public static let name = "MultiProvider"
public var metadata: ProviderMetadata

private let providers: [FeatureProvider]
private let strategy: Strategy

/// Initialize a MultiProvider with a list of providers and a strategy.
/// - Parameters:
/// - providers: A list of providers to evaluate.
/// - strategy: A strategy to evaluate the providers. Defaults to FirstMatchStrategy.
public init(
providers: [FeatureProvider],
strategy: Strategy = FirstMatchStrategy()
) {
self.providers = providers
self.strategy = strategy
metadata = MultiProviderMetadata(providers: providers)
}

public func initialize(initialContext: EvaluationContext?) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
for provider in providers {
group.addTask {
try await provider.initialize(initialContext: initialContext)
}
}
try await group.waitForAll()
}
}

public func onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
for provider in providers {
group.addTask {
try await provider.onContextSet(oldContext: oldContext, newContext: newContext)
}
}
try await group.waitForAll()
}
}

public func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws
-> ProviderEvaluation<Bool>
{
return try strategy.evaluate(
providers: providers,
key: key,
defaultValue: defaultValue,
evaluationContext: context
) { provider in
provider.getBooleanEvaluation(key:defaultValue:context:)
}
}

public func getStringEvaluation(key: String, defaultValue: String, context: EvaluationContext?) throws
-> ProviderEvaluation<String>
{
return try strategy.evaluate(
providers: providers,
key: key,
defaultValue: defaultValue,
evaluationContext: context
) { provider in
provider.getStringEvaluation(key:defaultValue:context:)
}
}

public func getIntegerEvaluation(key: String, defaultValue: Int64, context: EvaluationContext?) throws
-> ProviderEvaluation<Int64>
{
return try strategy.evaluate(
providers: providers,
key: key,
defaultValue: defaultValue,
evaluationContext: context
) { provider in
provider.getIntegerEvaluation(key:defaultValue:context:)
}
}

public func getDoubleEvaluation(key: String, defaultValue: Double, context: EvaluationContext?) throws
-> ProviderEvaluation<Double>
{
return try strategy.evaluate(
providers: providers,
key: key,
defaultValue: defaultValue,
evaluationContext: context
) { provider in
provider.getDoubleEvaluation(key:defaultValue:context:)
}
}

public func getObjectEvaluation(key: String, defaultValue: Value, context: EvaluationContext?) throws
-> ProviderEvaluation<Value>
{
return try strategy.evaluate(
providers: providers,
key: key,
defaultValue: defaultValue,
evaluationContext: context
) { provider in
provider.getObjectEvaluation(key:defaultValue:context:)
}
}

public func observe() -> AnyPublisher<ProviderEvent?, Never> {
return Publishers.MergeMany(providers.map { $0.observe() }).eraseToAnyPublisher()
}

public struct MultiProviderMetadata: ProviderMetadata {
public var name: String?

init(providers: [FeatureProvider]) {
name = "MultiProvider: " + providers.map {
$0.metadata.name ?? "Provider"
}
.joined(separator: ", ")
}
}
}
19 changes: 19 additions & 0 deletions Sources/OpenFeature/Provider/MultiProvider/Strategy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/// FlagEvaluation is a function that evaluates a feature flag and returns a ProviderEvaluation.
/// It is used to evaluate a feature flag across multiple providers using the strategy's logic.
public typealias FlagEvaluation<T> = (FeatureProvider) -> (
_ key: String, _ defaultValue: T, _ evaluationContext: EvaluationContext?
) throws -> ProviderEvaluation<T> where T: AllowedFlagValueType

/// Strategy interface defines how multiple feature providers should be evaluated
/// to determine the final result for a feature flag evaluation.
/// Different strategies can implement different logic for combining or selecting
/// results from multiple providers.
public protocol Strategy {
func evaluate<T>(
providers: [FeatureProvider],
key: String,
defaultValue: T,
evaluationContext: EvaluationContext?,
flagEvaluation: FlagEvaluation<T>
) throws -> ProviderEvaluation<T> where T: AllowedFlagValueType
}
61 changes: 61 additions & 0 deletions Tests/OpenFeatureTests/DeveloperExperienceTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Combine
import XCTest

@testable import OpenFeature
Expand Down Expand Up @@ -185,4 +186,64 @@ final class DeveloperExperienceTests: XCTestCase {
XCTAssertEqual(details.errorMessage, "A fatal error occurred in the provider: unknown")
XCTAssertEqual(details.reason, Reason.error.rawValue)
}

func testMultiProviderObserveEvents() async {
let mockEvent1Subject = CurrentValueSubject<ProviderEvent?, Never>(nil)
let mockEvent2Subject = CurrentValueSubject<ProviderEvent?, Never>(nil)
// Create test providers that can emit events
let eventEmittingProvider1 = MockProvider(
initialize: { _ in mockEvent1Subject.send(.ready(nil)) },
getBooleanEvaluation: { _, _, _ in throw OpenFeatureError.generalError(message: "test error") },
observe: { mockEvent1Subject.eraseToAnyPublisher() }
)
let eventEmittingProvider2 = MockProvider(
initialize: { _ in mockEvent2Subject.send(.ready(nil)) },
getBooleanEvaluation: { _, _, _ in throw OpenFeatureError.generalError(message: "test error") },
observe: { mockEvent2Subject.eraseToAnyPublisher() }
)
// Create MultiProvider with both providers
let multiProvider = MultiProvider(providers: [eventEmittingProvider1, eventEmittingProvider2])
// Set up expectations for different events
let readyExpectation = XCTestExpectation(description: "Ready event received")
let configChangedExpectation = XCTestExpectation(description: "Configuration changed event received")
let errorExpectation = XCTestExpectation(description: "Error event received")

var receivedEvents: [ProviderEvent] = []
// Observe events from MultiProvider
let observer = multiProvider.observe().sink { event in
guard let event = event else { return }
receivedEvents.append(event)

switch event {
case .ready:
readyExpectation.fulfill()
case .configurationChanged:
configChangedExpectation.fulfill()
case .error:
errorExpectation.fulfill()
default:
break
}
}

// Set the MultiProvider in OpenFeatureAPI to test integration
await OpenFeatureAPI.shared.setProviderAndWait(provider: multiProvider)

// Emit events from the first provider
mockEvent1Subject.send(.ready(nil))
mockEvent1Subject.send(.configurationChanged(nil))

// Emit events from the second provider
mockEvent2Subject.send(.error(ProviderEventDetails(message: "Test error", errorCode: .general)))
// Wait for all events to be received
await fulfillment(of: [readyExpectation, configChangedExpectation, errorExpectation], timeout: 2)

// Verify that events from both providers were received
XCTAssertTrue(receivedEvents.contains(.ready(nil)))
XCTAssertTrue(receivedEvents.contains(.configurationChanged(nil)))
XCTAssertTrue(receivedEvents.contains(.error(ProviderEventDetails(message: "Test error", errorCode: .general))))
XCTAssertGreaterThanOrEqual(receivedEvents.count, 3)

observer.cancel()
}
}
Loading
Loading