Skip to content

Commit 020b445

Browse files
committed
feat: resolve providers through strategy and return proper error as stated in documentation
1 parent 0e9f132 commit 020b445

File tree

4 files changed

+294
-34
lines changed

4 files changed

+294
-34
lines changed

src/implementation/multiprovider/Multiprovider.php

Lines changed: 242 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,19 @@
55
namespace OpenFeature\implementation\multiprovider;
66

77
use InvalidArgumentException;
8+
use OpenFeature\implementation\multiprovider\strategy\BaseEvaluationStrategy;
9+
use OpenFeature\implementation\multiprovider\strategy\FirstMatchStrategy;
10+
use OpenFeature\implementation\multiprovider\strategy\StrategyEvaluationContext;
11+
use OpenFeature\implementation\multiprovider\strategy\StrategyPerProviderContext;
12+
use OpenFeature\implementation\provider\AbstractProvider;
13+
use OpenFeature\implementation\provider\Reason;
14+
use OpenFeature\implementation\provider\ResolutionDetailsBuilder;
15+
use OpenFeature\implementation\provider\ResolutionError;
16+
use OpenFeature\interfaces\flags\EvaluationContext;
17+
use OpenFeature\interfaces\provider\ErrorCode;
818
use OpenFeature\interfaces\provider\Provider;
9-
use OpenFeature\interfaces\strategy\Strategy as StrategyInterface;
10-
use Psr\Log\LoggerAwareTrait;
19+
use OpenFeature\interfaces\provider\ResolutionDetails;
20+
use Throwable;
1121

1222
use function array_count_values;
1323
use function array_diff;
@@ -16,12 +26,17 @@
1626
use function array_map;
1727
use function count;
1828
use function implode;
29+
use function is_array;
30+
use function is_bool;
31+
use function is_float;
32+
use function is_int;
33+
use function is_string;
1934
use function strtolower;
2035
use function trim;
2136

22-
class Multiprovider
37+
class Multiprovider extends AbstractProvider
2338
{
24-
use LoggerAwareTrait;
39+
protected static string $NAME = 'Multiprovider';
2540

2641
/**
2742
* List of supported keys in each provider data entry.
@@ -39,16 +54,237 @@ class Multiprovider
3954
*/
4055
protected array $providersByName = [];
4156

57+
/**
58+
* The evaluation strategy to use for flag resolution.
59+
*/
60+
protected BaseEvaluationStrategy $strategy;
61+
4262
/**
4363
* Multiprovider constructor.
4464
*
4565
* @param array<int, array{name?: string, provider: Provider}> $providerData Array of provider data entries.
46-
* @param StrategyInterface|null $strategy Optional strategy instance.
66+
* @param BaseEvaluationStrategy|null $strategy Optional strategy instance.
4767
*/
48-
public function __construct(array $providerData = [], protected ?StrategyInterface $strategy = null)
68+
public function __construct(array $providerData = [], ?BaseEvaluationStrategy $strategy = null)
4969
{
5070
$this->validateProviderData($providerData);
5171
$this->registerProviders($providerData);
72+
73+
$this->strategy = $strategy ?? new FirstMatchStrategy();
74+
}
75+
76+
/**
77+
* Resolves the flag value for the provided flag key as a boolean
78+
*
79+
* @param string $flagKey The flag key to resolve
80+
* @param bool $defaultValue The default value to return if no provider resolves the flag
81+
* @param EvaluationContext|null $context The evaluation context
82+
*
83+
* @return ResolutionDetails The resolution details
84+
*/
85+
public function resolveBooleanValue(string $flagKey, bool $defaultValue, ?EvaluationContext $context = null): ResolutionDetails
86+
{
87+
return $this->evaluateFlag('boolean', $flagKey, $defaultValue, $context);
88+
}
89+
90+
/**
91+
* Resolves the flag value for the provided flag key as a string
92+
* * @param string $flagKey The flag key to resolve
93+
*
94+
* @param string $defaultValue The default value to return if no provider resolves the flag
95+
* @param EvaluationContext|null $context The evaluation context
96+
*
97+
* @return ResolutionDetails The resolution details
98+
*/
99+
public function resolveStringValue(string $flagKey, string $defaultValue, ?EvaluationContext $context = null): ResolutionDetails
100+
{
101+
return $this->evaluateFlag('string', $flagKey, $defaultValue, $context);
102+
}
103+
104+
/**
105+
* Resolves the flag value for the provided flag key as an integer
106+
* * @param string $flagKey The flag key to resolve
107+
*
108+
* @param int $defaultValue The default value to return if no provider resolves the flag
109+
* @param EvaluationContext|null $context The evaluation context
110+
*
111+
* @return ResolutionDetails The resolution details
112+
*/
113+
public function resolveIntegerValue(string $flagKey, int $defaultValue, ?EvaluationContext $context = null): ResolutionDetails
114+
{
115+
return $this->evaluateFlag('integer', $flagKey, $defaultValue, $context);
116+
}
117+
118+
/**
119+
* Resolves the flag value for the provided flag key as a float
120+
* * @param string $flagKey The flag key to resolve
121+
*
122+
* @param float $defaultValue The default value to return if no provider resolves the flag
123+
* @param EvaluationContext|null $context The evaluation context
124+
*
125+
* @return ResolutionDetails The resolution details
126+
*/
127+
public function resolveFloatValue(string $flagKey, float $defaultValue, ?EvaluationContext $context = null): ResolutionDetails
128+
{
129+
return $this->evaluateFlag('float', $flagKey, $defaultValue, $context);
130+
}
131+
132+
/**
133+
* Resolves the flag value for the provided flag key as an object
134+
*
135+
* @param string $flagKey The flag key to resolve
136+
* @param EvaluationContext|null $context The evaluation context
137+
* @param mixed[] $defaultValue
138+
*
139+
* @return ResolutionDetails The resolution details
140+
*/
141+
public function resolveObjectValue(string $flagKey, array $defaultValue, ?EvaluationContext $context = null): ResolutionDetails
142+
{
143+
return $this->evaluateFlag('object', $flagKey, $defaultValue, $context);
144+
}
145+
146+
/**
147+
* Core evaluation logic that works with the strategy to resolve flags across multiple providers.
148+
*/
149+
private function evaluateFlag(string $flagType, string $flagKey, mixed $defaultValue, ?EvaluationContext $context): ResolutionDetails
150+
{
151+
$context = $context ?? new \OpenFeature\implementation\flags\EvaluationContext();
152+
153+
// Create base evaluation context
154+
$baseContext = new StrategyEvaluationContext($flagKey, $flagType, $defaultValue, $context);
155+
156+
// Collect results from providers based on strategy
157+
if ($this->strategy->runMode === 'parallel') {
158+
$resolutions = $this->evaluateParallel($baseContext);
159+
} else {
160+
$resolutions = $this->evaluateSequential($baseContext);
161+
}
162+
163+
// Let strategy determine final result
164+
$finalResult = $this->strategy->determineFinalResult($baseContext, $resolutions);
165+
166+
if ($finalResult->isSuccessful()) {
167+
$details = $finalResult->getDetails();
168+
if ($details instanceof ResolutionDetails) {
169+
return $details;
170+
}
171+
}
172+
173+
// Handle error case
174+
return $this->createErrorResolution($flagKey, $defaultValue, $finalResult->getErrors());
175+
}
176+
177+
/**
178+
* Evaluate providers sequentially based on strategy decisions.
179+
*
180+
* @return array<int, ProviderResolutionResult> Array of resolution results from evaluated providers.
181+
*/
182+
private function evaluateSequential(StrategyEvaluationContext $baseContext): array
183+
{
184+
$resolutions = [];
185+
186+
foreach ($this->providersByName as $providerName => $provider) {
187+
$perProviderContext = new StrategyPerProviderContext($baseContext, $providerName, $provider);
188+
189+
// Check if we should evaluate this provider
190+
if (!$this->strategy->shouldEvaluateThisProvider($perProviderContext)) {
191+
continue;
192+
}
193+
194+
// Evaluate provider
195+
$result = $this->evaluateProvider($provider, $providerName, $baseContext);
196+
$resolutions[] = $result;
197+
198+
// Check if we should continue to next provider
199+
if (!$this->strategy->shouldEvaluateNextProvider($perProviderContext, $result)) {
200+
break;
201+
}
202+
}
203+
204+
return $resolutions;
205+
}
206+
207+
/**
208+
* Evaluate all providers in parallel (all that pass shouldEvaluateThisProvider).
209+
*
210+
* @return array<int, ProviderResolutionResult> Array of resolution results from evaluated providers.
211+
*/
212+
private function evaluateParallel(StrategyEvaluationContext $baseContext): array
213+
{
214+
$resolutions = [];
215+
216+
foreach ($this->providersByName as $providerName => $provider) {
217+
$perProviderContext = new StrategyPerProviderContext($baseContext, $providerName, $provider);
218+
219+
// Check if we should evaluate this provider
220+
if (!$this->strategy->shouldEvaluateThisProvider($perProviderContext)) {
221+
continue;
222+
}
223+
224+
// Evaluate provider
225+
$result = $this->evaluateProvider($provider, $providerName, $baseContext);
226+
$resolutions[] = $result;
227+
}
228+
229+
return $resolutions;
230+
}
231+
232+
/**
233+
* Evaluate a single provider and return result with error handling.
234+
*/
235+
private function evaluateProvider(Provider $provider, string $providerName, StrategyEvaluationContext $context): ProviderResolutionResult
236+
{
237+
try {
238+
$flagType = $context->getFlagType();
239+
/** @var bool|string|int|float|array<mixed>|null $defaultValue */
240+
$defaultValue = $context->getDefaultValue();
241+
$evalContext = $context->getEvaluationContext();
242+
243+
$details = match ($flagType) {
244+
'boolean' => is_bool($defaultValue)
245+
? $provider->resolveBooleanValue($context->getFlagKey(), $defaultValue, $evalContext)
246+
: throw new InvalidArgumentException('Default value for boolean flag must be bool'),
247+
'string' => is_string($defaultValue)
248+
? $provider->resolveStringValue($context->getFlagKey(), $defaultValue, $evalContext)
249+
: throw new InvalidArgumentException('Default value for string flag must be string'),
250+
'integer' => is_int($defaultValue)
251+
? $provider->resolveIntegerValue($context->getFlagKey(), $defaultValue, $evalContext)
252+
: throw new InvalidArgumentException('Default value for integer flag must be int'),
253+
'float' => is_float($defaultValue)
254+
? $provider->resolveFloatValue($context->getFlagKey(), $defaultValue, $evalContext)
255+
: throw new InvalidArgumentException('Default value for float flag must be float'),
256+
'object' => is_array($defaultValue)
257+
? $provider->resolveObjectValue($context->getFlagKey(), $defaultValue, $evalContext)
258+
: throw new InvalidArgumentException('Default value for object flag must be array'),
259+
default => throw new InvalidArgumentException('Unknown flag type: ' . $flagType),
260+
};
261+
262+
return new ProviderResolutionResult($providerName, $provider, $details, null);
263+
} catch (Throwable $error) {
264+
return new ProviderResolutionResult($providerName, $provider, null, $error);
265+
}
266+
}
267+
268+
/**
269+
* Create an error resolution with aggregated errors from multiple providers.
270+
*
271+
* @param string $flagKey The flag key being evaluated.
272+
* @param mixed $defaultValue The default value to return.
273+
* @param array<int, array{providerName: string, error: Throwable}>|null $errors Array of errors encountered during evaluation.
274+
*/
275+
private function createErrorResolution(string $flagKey, mixed $defaultValue, ?array $errors): ResolutionDetails
276+
{
277+
$errorMessage = 'Multi-provider evaluation failed';
278+
$errorCode = ErrorCode::GENERAL();
279+
280+
if ($errors !== null && count($errors) > 0) {
281+
$errorMessage .= ' with ' . count($errors) . ' provider error(s)';
282+
}
283+
284+
return (new ResolutionDetailsBuilder())
285+
->withReason(Reason::ERROR)
286+
->withError(new ResolutionError($errorCode, $errorMessage))
287+
->build();
52288
}
53289

54290
/**

src/implementation/multiprovider/strategy/ComparisonStrategy.php

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
use Throwable;
1010

1111
use function count;
12-
use function is_callable;
1312

1413
/**
1514
* ComparisonStrategy requires all providers to agree on a value.
@@ -36,6 +35,16 @@ public function __construct(
3635
) {
3736
}
3837

38+
public function getFallbackProviderName(): ?string
39+
{
40+
return $this->fallbackProviderName;
41+
}
42+
43+
public function getOnMismatch(): ?callable
44+
{
45+
return $this->onMismatch;
46+
}
47+
3948
/**
4049
* All providers should be evaluated by default.
4150
* This allows for comparison of results across providers.
@@ -73,7 +82,7 @@ public function shouldEvaluateNextProvider(
7382
* If no successful results, returns aggregated errors.
7483
*
7584
* @param StrategyEvaluationContext $context Context for the overall evaluation
76-
* @param array<int, ProviderResolutionResult> $resolutions Array of resolution results from all providers
85+
* @param ProviderResolutionResult[] $resolutions Array of resolution results from all providers
7786
*
7887
* @return FinalResult The final result of the evaluation
7988
*/
@@ -86,19 +95,22 @@ public function determineFinalResult(
8695
$errors = [];
8796

8897
foreach ($resolutions as $resolution) {
89-
if ($resolution->isSuccessful()) {
98+
if ($resolution->hasError()) {
99+
$err = $resolution->getError();
100+
if ($err instanceof Throwable) {
101+
$errors[] = [
102+
'providerName' => $resolution->getProviderName(),
103+
'error' => $err,
104+
];
105+
}
106+
} else {
90107
$successfulResults[] = $resolution;
91-
} elseif ($resolution->hasError()) {
92-
$errors[] = [
93-
'providerName' => $resolution->getProviderName(),
94-
'error' => $resolution->getError(),
95-
];
96108
}
97109
}
98110

99111
// If no successful results, return errors
100112
if (count($successfulResults) === 0) {
101-
return new FinalResult(null, null, $errors ?: null);
113+
return new FinalResult(null, null, $errors !== [] ? $errors : null);
102114
}
103115

104116
// If only one successful result, return it
@@ -113,11 +125,13 @@ public function determineFinalResult(
113125
}
114126

115127
// Compare all successful values
116-
$firstValue = $successfulResults[0]->getDetails()->getValue();
128+
$firstDetails = $successfulResults[0]->getDetails();
129+
$firstValue = $firstDetails ? $firstDetails->getValue() : null;
117130
$allMatch = true;
118131

119132
foreach ($successfulResults as $result) {
120-
if ($result->getDetails()->getValue() !== $firstValue) {
133+
$details = $result->getDetails();
134+
if (!$details || $details->getValue() !== $firstValue) {
121135
$allMatch = false;
122136

123137
break;
@@ -136,18 +150,20 @@ public function determineFinalResult(
136150
}
137151

138152
// Values don't match - call onMismatch callback if provided
139-
if ($this->onMismatch !== null && is_callable($this->onMismatch)) {
153+
$onMismatch = $this->getOnMismatch();
154+
if ($onMismatch !== null) {
140155
try {
141-
($this->onMismatch)($successfulResults);
156+
$onMismatch($successfulResults);
142157
} catch (Throwable $e) {
143158
// Ignore errors from callback
144159
}
145160
}
146161

147162
// Return fallback provider result if configured
148-
if ($this->fallbackProviderName !== null) {
163+
$fallbackProviderName = $this->getFallbackProviderName();
164+
if ($fallbackProviderName !== null) {
149165
foreach ($successfulResults as $result) {
150-
if ($result->getProviderName() === $this->fallbackProviderName) {
166+
if ($result->getProviderName() === $fallbackProviderName) {
151167
return new FinalResult(
152168
$result->getDetails(),
153169
$result->getProviderName(),

0 commit comments

Comments
 (0)