Skip to content

Commit 869b90a

Browse files
authored
feat: add multiprovider (#78)
## This PR - Adds multiprovider with `FirstMatchStrategy` and `FirstSuccessfulStrategy` implementations - Effectively is a port of this kotlin-sdk update: open-feature/kotlin-sdk#168 to swift ### Related Issues #68 ### Follow-up Tasks - Update documentation ### How to test - Adds unit test coverage, but open to ideas on additional ways to test! --------- Signed-off-by: jescriba <[email protected]> Signed-off-by: Joshua E. <[email protected]>
1 parent 56b477e commit 869b90a

File tree

7 files changed

+686
-1
lines changed

7 files changed

+686
-1
lines changed

README.md

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ Task {
115115
|| [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
116116
|| [Tracking](#tracking) | Associate user actions with feature flag evaluations. |
117117
|| [Logging](#logging) | Integrate with popular logging packages. |
118-
| | [Named clients](#named-clients) | Utilize multiple providers in a single application. |
118+
| | [MultiProvider](#multiprovider) | Utilize multiple providers in a single application. |
119119
|| [Eventing](#eventing) | React to state changes in the provider or flag management system. |
120120
|| [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
121121
|| [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
@@ -184,6 +184,89 @@ Logging customization is not yet available in the iOS SDK.
184184

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

187+
### MultiProvider
188+
189+
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.
190+
191+
#### Basic Usage
192+
193+
```swift
194+
import OpenFeature
195+
196+
Task {
197+
// Create individual providers
198+
let primaryProvider = PrimaryProvider()
199+
let fallbackProvider = FallbackProvider()
200+
201+
// Create a MultiProvider with default FirstMatchStrategy
202+
let multiProvider = MultiProvider(providers: [primaryProvider, fallbackProvider])
203+
204+
// Set the MultiProvider as the global provider
205+
await OpenFeatureAPI.shared.setProviderAndWait(provider: multiProvider)
206+
207+
// Use flags normally - the MultiProvider will handle provider selection
208+
let client = OpenFeatureAPI.shared.getClient()
209+
let flagValue = client.getBooleanValue(key: "my-flag", defaultValue: false)
210+
}
211+
```
212+
213+
#### Evaluation Strategies
214+
215+
The `MultiProvider` supports different strategies for evaluating flags across multiple providers:
216+
217+
##### FirstMatchStrategy (Default)
218+
219+
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.
220+
221+
```swift
222+
let multiProvider = MultiProvider(
223+
providers: [primaryProvider, fallbackProvider],
224+
strategy: FirstMatchStrategy()
225+
)
226+
```
227+
228+
##### FirstSuccessfulStrategy
229+
230+
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".
231+
232+
```swift
233+
let multiProvider = MultiProvider(
234+
providers: [primaryProvider, fallbackProvider],
235+
strategy: FirstSuccessfulStrategy()
236+
)
237+
```
238+
239+
#### Use Cases
240+
241+
**Provider Migration:**
242+
```swift
243+
// Gradually migrate from OldProvider to NewProvider
244+
let multiProvider = MultiProvider(providers: [
245+
NewProvider(), // Check new provider first
246+
OldProvider() // Fall back to old provider
247+
])
248+
```
249+
250+
**High Availability:**
251+
```swift
252+
// Use multiple providers for redundancy
253+
let multiProvider = MultiProvider(providers: [
254+
RemoteProvider(),
255+
LocalCacheProvider(),
256+
StaticProvider()
257+
])
258+
```
259+
260+
**Environment-Specific Providers:**
261+
```swift
262+
// Different providers for different environments
263+
let providers = [
264+
EnvironmentProvider(environment: "production"),
265+
DefaultProvider()
266+
]
267+
let multiProvider = MultiProvider(providers: providers)
268+
```
269+
187270
### Eventing
188271

189272
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.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/// FirstMatchStrategy is a strategy that evaluates a feature flag across multiple providers
2+
/// and returns the first result. Skips providers that indicate they had no value due to flag not found.
3+
/// If any provider returns an error result other than flag not found, the error is returned.
4+
final public class FirstMatchStrategy: Strategy {
5+
public init() {}
6+
7+
public func evaluate<T>(
8+
providers: [FeatureProvider],
9+
key: String,
10+
defaultValue: T,
11+
evaluationContext: EvaluationContext?,
12+
flagEvaluation: FlagEvaluation<T>
13+
) throws -> ProviderEvaluation<T> where T: AllowedFlagValueType {
14+
for provider in providers {
15+
do {
16+
let eval = try flagEvaluation(provider)(key, defaultValue, evaluationContext)
17+
if eval.errorCode != ErrorCode.flagNotFound {
18+
return eval
19+
}
20+
} catch OpenFeatureError.flagNotFoundError {
21+
continue
22+
} catch let error as OpenFeatureError {
23+
return ProviderEvaluation(
24+
value: defaultValue,
25+
reason: Reason.error.rawValue,
26+
errorCode: error.errorCode(),
27+
errorMessage: error.description
28+
)
29+
} catch {
30+
throw error
31+
}
32+
}
33+
34+
return ProviderEvaluation(
35+
value: defaultValue,
36+
reason: Reason.defaultReason.rawValue,
37+
errorCode: ErrorCode.flagNotFound
38+
)
39+
}
40+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/// FirstSuccessfulStrategy is a strategy that evaluates a feature flag across multiple providers
2+
/// and returns the first result. Similar to `FirstMatchStrategy` but does not bubble up individual provider errors.
3+
/// If no provider successfully responds, it will return an error.
4+
final public class FirstSuccessfulStrategy: Strategy {
5+
public func evaluate<T>(
6+
providers: [FeatureProvider],
7+
key: String,
8+
defaultValue: T,
9+
evaluationContext: EvaluationContext?,
10+
flagEvaluation: FlagEvaluation<T>
11+
) throws -> ProviderEvaluation<T> where T: AllowedFlagValueType {
12+
var flagNotFound = false
13+
for provider in providers {
14+
do {
15+
let eval = try flagEvaluation(provider)(key, defaultValue, evaluationContext)
16+
if eval.errorCode == nil {
17+
return eval
18+
} else if eval.errorCode == ErrorCode.flagNotFound {
19+
flagNotFound = true
20+
}
21+
} catch OpenFeatureError.flagNotFoundError {
22+
flagNotFound = true
23+
} catch {
24+
continue
25+
}
26+
}
27+
28+
let errorCode = flagNotFound ? ErrorCode.flagNotFound : ErrorCode.general
29+
return ProviderEvaluation(
30+
value: defaultValue,
31+
reason: Reason.defaultReason.rawValue,
32+
errorCode: errorCode
33+
)
34+
}
35+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import Combine
2+
import Foundation
3+
4+
/// A provider that combines multiple providers into a single provider.
5+
public class MultiProvider: FeatureProvider {
6+
public var hooks: [any Hook] {
7+
[]
8+
}
9+
10+
public static let name = "MultiProvider"
11+
public var metadata: ProviderMetadata
12+
13+
private let providers: [FeatureProvider]
14+
private let strategy: Strategy
15+
16+
/// Initialize a MultiProvider with a list of providers and a strategy.
17+
/// - Parameters:
18+
/// - providers: A list of providers to evaluate.
19+
/// - strategy: A strategy to evaluate the providers. Defaults to FirstMatchStrategy.
20+
public init(
21+
providers: [FeatureProvider],
22+
strategy: Strategy = FirstMatchStrategy()
23+
) {
24+
self.providers = providers
25+
self.strategy = strategy
26+
metadata = MultiProviderMetadata(providers: providers)
27+
}
28+
29+
public func initialize(initialContext: EvaluationContext?) async throws {
30+
try await withThrowingTaskGroup(of: Void.self) { group in
31+
for provider in providers {
32+
group.addTask {
33+
try await provider.initialize(initialContext: initialContext)
34+
}
35+
}
36+
try await group.waitForAll()
37+
}
38+
}
39+
40+
public func onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) async throws {
41+
try await withThrowingTaskGroup(of: Void.self) { group in
42+
for provider in providers {
43+
group.addTask {
44+
try await provider.onContextSet(oldContext: oldContext, newContext: newContext)
45+
}
46+
}
47+
try await group.waitForAll()
48+
}
49+
}
50+
51+
public func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws
52+
-> ProviderEvaluation<Bool>
53+
{
54+
return try strategy.evaluate(
55+
providers: providers,
56+
key: key,
57+
defaultValue: defaultValue,
58+
evaluationContext: context
59+
) { provider in
60+
provider.getBooleanEvaluation(key:defaultValue:context:)
61+
}
62+
}
63+
64+
public func getStringEvaluation(key: String, defaultValue: String, context: EvaluationContext?) throws
65+
-> ProviderEvaluation<String>
66+
{
67+
return try strategy.evaluate(
68+
providers: providers,
69+
key: key,
70+
defaultValue: defaultValue,
71+
evaluationContext: context
72+
) { provider in
73+
provider.getStringEvaluation(key:defaultValue:context:)
74+
}
75+
}
76+
77+
public func getIntegerEvaluation(key: String, defaultValue: Int64, context: EvaluationContext?) throws
78+
-> ProviderEvaluation<Int64>
79+
{
80+
return try strategy.evaluate(
81+
providers: providers,
82+
key: key,
83+
defaultValue: defaultValue,
84+
evaluationContext: context
85+
) { provider in
86+
provider.getIntegerEvaluation(key:defaultValue:context:)
87+
}
88+
}
89+
90+
public func getDoubleEvaluation(key: String, defaultValue: Double, context: EvaluationContext?) throws
91+
-> ProviderEvaluation<Double>
92+
{
93+
return try strategy.evaluate(
94+
providers: providers,
95+
key: key,
96+
defaultValue: defaultValue,
97+
evaluationContext: context
98+
) { provider in
99+
provider.getDoubleEvaluation(key:defaultValue:context:)
100+
}
101+
}
102+
103+
public func getObjectEvaluation(key: String, defaultValue: Value, context: EvaluationContext?) throws
104+
-> ProviderEvaluation<Value>
105+
{
106+
return try strategy.evaluate(
107+
providers: providers,
108+
key: key,
109+
defaultValue: defaultValue,
110+
evaluationContext: context
111+
) { provider in
112+
provider.getObjectEvaluation(key:defaultValue:context:)
113+
}
114+
}
115+
116+
public func observe() -> AnyPublisher<ProviderEvent?, Never> {
117+
return Publishers.MergeMany(providers.map { $0.observe() }).eraseToAnyPublisher()
118+
}
119+
120+
public struct MultiProviderMetadata: ProviderMetadata {
121+
public var name: String?
122+
123+
init(providers: [FeatureProvider]) {
124+
name = "MultiProvider: " + providers.map {
125+
$0.metadata.name ?? "Provider"
126+
}
127+
.joined(separator: ", ")
128+
}
129+
}
130+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/// FlagEvaluation is a function that evaluates a feature flag and returns a ProviderEvaluation.
2+
/// It is used to evaluate a feature flag across multiple providers using the strategy's logic.
3+
public typealias FlagEvaluation<T> = (FeatureProvider) -> (
4+
_ key: String, _ defaultValue: T, _ evaluationContext: EvaluationContext?
5+
) throws -> ProviderEvaluation<T> where T: AllowedFlagValueType
6+
7+
/// Strategy interface defines how multiple feature providers should be evaluated
8+
/// to determine the final result for a feature flag evaluation.
9+
/// Different strategies can implement different logic for combining or selecting
10+
/// results from multiple providers.
11+
public protocol Strategy {
12+
func evaluate<T>(
13+
providers: [FeatureProvider],
14+
key: String,
15+
defaultValue: T,
16+
evaluationContext: EvaluationContext?,
17+
flagEvaluation: FlagEvaluation<T>
18+
) throws -> ProviderEvaluation<T> where T: AllowedFlagValueType
19+
}

Tests/OpenFeatureTests/DeveloperExperienceTests.swift

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Combine
12
import XCTest
23

34
@testable import OpenFeature
@@ -185,4 +186,64 @@ final class DeveloperExperienceTests: XCTestCase {
185186
XCTAssertEqual(details.errorMessage, "A fatal error occurred in the provider: unknown")
186187
XCTAssertEqual(details.reason, Reason.error.rawValue)
187188
}
189+
190+
func testMultiProviderObserveEvents() async {
191+
let mockEvent1Subject = CurrentValueSubject<ProviderEvent?, Never>(nil)
192+
let mockEvent2Subject = CurrentValueSubject<ProviderEvent?, Never>(nil)
193+
// Create test providers that can emit events
194+
let eventEmittingProvider1 = MockProvider(
195+
initialize: { _ in mockEvent1Subject.send(.ready(nil)) },
196+
getBooleanEvaluation: { _, _, _ in throw OpenFeatureError.generalError(message: "test error") },
197+
observe: { mockEvent1Subject.eraseToAnyPublisher() }
198+
)
199+
let eventEmittingProvider2 = MockProvider(
200+
initialize: { _ in mockEvent2Subject.send(.ready(nil)) },
201+
getBooleanEvaluation: { _, _, _ in throw OpenFeatureError.generalError(message: "test error") },
202+
observe: { mockEvent2Subject.eraseToAnyPublisher() }
203+
)
204+
// Create MultiProvider with both providers
205+
let multiProvider = MultiProvider(providers: [eventEmittingProvider1, eventEmittingProvider2])
206+
// Set up expectations for different events
207+
let readyExpectation = XCTestExpectation(description: "Ready event received")
208+
let configChangedExpectation = XCTestExpectation(description: "Configuration changed event received")
209+
let errorExpectation = XCTestExpectation(description: "Error event received")
210+
211+
var receivedEvents: [ProviderEvent] = []
212+
// Observe events from MultiProvider
213+
let observer = multiProvider.observe().sink { event in
214+
guard let event = event else { return }
215+
receivedEvents.append(event)
216+
217+
switch event {
218+
case .ready:
219+
readyExpectation.fulfill()
220+
case .configurationChanged:
221+
configChangedExpectation.fulfill()
222+
case .error:
223+
errorExpectation.fulfill()
224+
default:
225+
break
226+
}
227+
}
228+
229+
// Set the MultiProvider in OpenFeatureAPI to test integration
230+
await OpenFeatureAPI.shared.setProviderAndWait(provider: multiProvider)
231+
232+
// Emit events from the first provider
233+
mockEvent1Subject.send(.ready(nil))
234+
mockEvent1Subject.send(.configurationChanged(nil))
235+
236+
// Emit events from the second provider
237+
mockEvent2Subject.send(.error(ProviderEventDetails(message: "Test error", errorCode: .general)))
238+
// Wait for all events to be received
239+
await fulfillment(of: [readyExpectation, configChangedExpectation, errorExpectation], timeout: 2)
240+
241+
// Verify that events from both providers were received
242+
XCTAssertTrue(receivedEvents.contains(.ready(nil)))
243+
XCTAssertTrue(receivedEvents.contains(.configurationChanged(nil)))
244+
XCTAssertTrue(receivedEvents.contains(.error(ProviderEventDetails(message: "Test error", errorCode: .general))))
245+
XCTAssertGreaterThanOrEqual(receivedEvents.count, 3)
246+
247+
observer.cancel()
248+
}
188249
}

0 commit comments

Comments
 (0)