Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
platform: [iOS, macOS, watchOS, tvOS]
include:
- platform: iOS
destination: "platform=iOS Simulator,name=iPhone 15"
destination: "platform=iOS Simulator,name=iPhone 16"
- platform: macOS
destination: "platform=macOS"
- platform: watchOS
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,29 @@
/// 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 {
for provider in providers {
do {
let eval = try flagEvaluation(provider)(key, defaultValue, evaluationContext)
if eval.errorCode == nil {
return eval
}
} catch {
continue
}
}

return ProviderEvaluation(
value: defaultValue,
reason: Reason.defaultReason.rawValue,
errorCode: ErrorCode.flagNotFound
)
}
}
122 changes: 122 additions & 0 deletions Sources/OpenFeature/Provider/MultiProvider/MultiProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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 = MultiProvider.MultiProviderMetadata()

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
}

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
return 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
return 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
return 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
return 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
return 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? = MultiProvider.name
}
}
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
}
20 changes: 12 additions & 8 deletions Tests/OpenFeatureTests/EvalContextTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,12 @@ final class EvalContextTests: XCTestCase {
originalContext.add(key: "integer", value: .integer(42))
originalContext.add(key: "boolean", value: .boolean(true))
originalContext.add(key: "list", value: .list([.string("item1"), .integer(100)]))
originalContext.add(key: "structure", value: .structure([
"nested-string": .string("nested-value"),
"nested-int": .integer(200),
]))
originalContext.add(
key: "structure",
value: .structure([
"nested-string": .string("nested-value"),
"nested-int": .integer(200),
]))

guard let copiedContext = originalContext.deepCopy() as? MutableContext else {
XCTFail("Failed to cast to MutableContext")
Expand Down Expand Up @@ -207,10 +209,12 @@ final class EvalContextTests: XCTestCase {
originalContext.add(key: "double", value: .double(3.14159))
originalContext.add(key: "date", value: .date(date))
originalContext.add(key: "list", value: .list([.string("list-item"), .integer(999)]))
originalContext.add(key: "structure", value: .structure([
"struct-key": .string("struct-value"),
"struct-number": .integer(777),
]))
originalContext.add(
key: "structure",
value: .structure([
"struct-key": .string("struct-value"),
"struct-number": .integer(777),
]))

guard let copiedContext = originalContext.deepCopy() as? MutableContext else {
XCTFail("Failed to cast to MutableContext")
Expand Down
134 changes: 134 additions & 0 deletions Tests/OpenFeatureTests/Helpers/MockProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import Combine
import Foundation

@testable import OpenFeature

/// A mock provider that can be used to test provider events with payloads.
/// It can be configured with a set of callbacks that will be called when the provider is initialized
class MockProvider: FeatureProvider {
static let name = "MockProvider"
var metadata: ProviderMetadata = MockProviderMetadata()

var hooks: [any Hook] = []
var throwFatal = false
private let _onContextSet: (EvaluationContext?, EvaluationContext) async throws -> Void
private let _initialize: (EvaluationContext?) async throws -> Void
private let _getBooleanEvaluation: (String, Bool, EvaluationContext?) throws -> ProviderEvaluation<Bool>
private let _getStringEvaluation: (String, String, EvaluationContext?) throws -> ProviderEvaluation<String>
private let _getIntegerEvaluation: (String, Int64, EvaluationContext?) throws -> ProviderEvaluation<Int64>
private let _getDoubleEvaluation: (String, Double, EvaluationContext?) throws -> ProviderEvaluation<Double>
private let _getObjectEvaluation: (String, Value, EvaluationContext?) throws -> ProviderEvaluation<Value>
private let _observe: () -> AnyPublisher<ProviderEvent?, Never>

/// Initialize the provider with a set of callbacks that will be called when the provider is initialized,
init(
onContextSet: @escaping (EvaluationContext?, EvaluationContext) async throws -> Void = { _, _ in },
initialize: @escaping (EvaluationContext?) async throws -> Void = { _ in },
getBooleanEvaluation: @escaping (
String,
Bool,
EvaluationContext?
) throws -> ProviderEvaluation<Bool> = { _, fallback, _ in
return ProviderEvaluation(value: fallback, flagMetadata: [:])
},
getStringEvaluation: @escaping (
String,
String,
EvaluationContext?
) throws -> ProviderEvaluation<String> = { _, fallback, _ in
return ProviderEvaluation(value: fallback, flagMetadata: [:])
},
getIntegerEvaluation: @escaping (
String,
Int64,
EvaluationContext?
) throws -> ProviderEvaluation<Int64> = { _, fallback, _ in
return ProviderEvaluation(value: fallback, flagMetadata: [:])
},
getDoubleEvaluation: @escaping (
String,
Double,
EvaluationContext?
) throws -> ProviderEvaluation<Double> = { _, fallback, _ in
return ProviderEvaluation(value: fallback, flagMetadata: [:])
},
getObjectEvaluation: @escaping (
String,
Value,
EvaluationContext?
) throws -> ProviderEvaluation<Value> = { _, fallback, _ in
return ProviderEvaluation(value: fallback, flagMetadata: [:])
},
observe: @escaping () -> AnyPublisher<ProviderEvent?, Never> = { Just(nil).eraseToAnyPublisher() }
) {
self._onContextSet = onContextSet
self._initialize = initialize
self._getBooleanEvaluation = getBooleanEvaluation
self._getStringEvaluation = getStringEvaluation
self._getIntegerEvaluation = getIntegerEvaluation
self._getDoubleEvaluation = getDoubleEvaluation
self._getObjectEvaluation = getObjectEvaluation
self._observe = observe
}

func onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) async throws {
try await _onContextSet(oldContext, newContext)
}

func initialize(initialContext: EvaluationContext?) async throws {
try await _initialize(initialContext)
}

func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws
-> ProviderEvaluation<Bool>
{
try _getBooleanEvaluation(key, defaultValue, context)
}

func getStringEvaluation(key: String, defaultValue: String, context: EvaluationContext?) throws
-> ProviderEvaluation<String>
{
try _getStringEvaluation(key, defaultValue, context)
}

func getIntegerEvaluation(key: String, defaultValue: Int64, context: EvaluationContext?) throws
-> ProviderEvaluation<Int64>
{
try _getIntegerEvaluation(key, defaultValue, context)
}

func getDoubleEvaluation(key: String, defaultValue: Double, context: EvaluationContext?) throws
-> ProviderEvaluation<Double>
{
try _getDoubleEvaluation(key, defaultValue, context)
}

func getObjectEvaluation(key: String, defaultValue: Value, context: EvaluationContext?) throws
-> ProviderEvaluation<Value>
{
try _getObjectEvaluation(key, defaultValue, context)
}

func observe() -> AnyPublisher<ProviderEvent?, Never> {
_observe()
}
}

extension MockProvider {
struct MockProviderMetadata: ProviderMetadata {
var name: String? = MockProvider.name
}
}

extension MockProvider {
enum MockProviderError: LocalizedError {
case message(String)

var errorDescription: String? {
switch self {
case .message(let message):
return message
}
}
}
}
Loading